diff --git a/packages/bruno-api-docs/e2e/components/request/code-snippet.component.ts b/packages/bruno-api-docs/e2e/components/request/code-snippet.component.ts index 317d37dc..0ec3c85d 100644 --- a/packages/bruno-api-docs/e2e/components/request/code-snippet.component.ts +++ b/packages/bruno-api-docs/e2e/components/request/code-snippet.component.ts @@ -1,18 +1,24 @@ -import type { Locator } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { BaseComponent } from '../base.component'; export class CodeSnippetComponent extends BaseComponent { - readonly root = this.page.getByTestId('request-code-snippet'); - - readonly code = this.root.getByTestId('code-snippet-code'); - - readonly copyButton = this.root.getByTestId('code-snippet-code-copy'); - - readonly expandButton = this.root.getByTestId('code-snippet-expand'); - - readonly modal = this.page.getByTestId('code-snippet-modal'); - - readonly modalCode = this.modal.getByTestId('code-snippet-code'); + readonly code: Locator; + readonly copyButton: Locator; + readonly expandButton: Locator; + readonly modal: Locator; + readonly modalCode: Locator; + + constructor( + page: Page, + private readonly base = 'request-code-snippet' + ) { + super(page, page.getByTestId(base)); + this.code = this.root.getByTestId(`${base}-code`); + this.copyButton = this.root.getByTestId(`${base}-code-copy`); + this.expandButton = this.root.getByTestId(`${base}-expand`); + this.modal = page.getByTestId(`${base}-modal`); + this.modalCode = this.modal.getByTestId(`${base}-code`); + } variableToken(name: string): Locator { return this.code.getByTestId(`variable-token-${name}`).first(); @@ -23,11 +29,11 @@ export class CodeSnippetComponent extends BaseComponent { } languageTab(language: string): Locator { - return this.root.getByTestId(`code-snippet-tab-${language}`); + return this.root.getByTestId(`${this.base}-tab-${language}`); } modalLanguageTab(language: string): Locator { - return this.modal.getByTestId(`code-snippet-tab-${language}`); + return this.modal.getByTestId(`${this.base}-tab-${language}`); } async selectLanguage(language: string): Promise { diff --git a/packages/bruno-api-docs/e2e/components/request/examples.component.ts b/packages/bruno-api-docs/e2e/components/request/examples.component.ts index a90b3988..b37e16b7 100644 --- a/packages/bruno-api-docs/e2e/components/request/examples.component.ts +++ b/packages/bruno-api-docs/e2e/components/request/examples.component.ts @@ -1,7 +1,10 @@ import type { Locator } from '@playwright/test'; import { BaseComponent } from '../base.component'; +import { CodeSnippetComponent } from './code-snippet.component'; export class ExamplesComponent extends BaseComponent { + readonly snippet = new CodeSnippetComponent(this.page, 'example-code-snippet'); + readonly root = this.page.getByTestId('request-examples'); readonly items = this.root.getByTestId('example-card'); @@ -32,14 +35,14 @@ export class ExamplesComponent extends BaseComponent { // The snippet dialog is portalled to , so it is scoped to the page, not the card. readonly snippetModal = this.page.getByRole('dialog', { name: 'Code snippet' }); - readonly snippetCode = this.snippetModal.getByTestId('code-snippet-code'); + readonly snippetCode = this.snippet.modalCode; snippetButton(name: string): Locator { return this.example(name).getByTestId('example-code-snippet-trigger'); } snippetLanguageTab(language: string): Locator { - return this.snippetModal.getByTestId(`code-snippet-tab-${language}`); + return this.snippet.modalLanguageTab(language); } async openSnippet(name: string): Promise { diff --git a/packages/bruno-api-docs/e2e/components/request/grpc-messages.component.ts b/packages/bruno-api-docs/e2e/components/request/grpc-messages.component.ts new file mode 100644 index 00000000..e68c373f --- /dev/null +++ b/packages/bruno-api-docs/e2e/components/request/grpc-messages.component.ts @@ -0,0 +1,34 @@ +import type { Locator, Page } from '@playwright/test'; +import { BaseComponent } from '../base.component'; + +export class GrpcMessagesComponent extends BaseComponent { + readonly showToggle: Locator; + + constructor( + page: Page, + private readonly base = 'grpc-messages' + ) { + super(page, page.getByTestId(base)); + this.showToggle = page.getByTestId(`${base}-show-toggle`); + } + + card(index: number): Locator { + return this.page.getByTestId(`${this.base}-card-${index}`); + } + + toggle(index: number): Locator { + return this.page.getByTestId(`${this.base}-card-${index}-toggle`); + } + + code(index: number): Locator { + return this.page.getByTestId(`${this.base}-card-${index}-code`); + } + + async expand(index: number): Promise { + await this.toggle(index).click(); + } + + async showMore(): Promise { + await this.showToggle.click(); + } +} diff --git a/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts new file mode 100644 index 00000000..ea94cd63 --- /dev/null +++ b/packages/bruno-api-docs/e2e/pages/grpc-request.page.ts @@ -0,0 +1,48 @@ +import type { Locator } from '@playwright/test'; +import { BasePage } from './base.page'; +import { SidebarComponent } from '../components/sidebar.component'; +import { BreadcrumbComponent } from '../components/breadcrumb.component'; +import { RequestUrlBarComponent } from '../components/request/url-bar.component'; +import { ExecutionContextComponent } from '../components/request/execution-context.component'; +import { CodeSnippetComponent } from '../components/request/code-snippet.component'; +import { GrpcMessagesComponent } from '../components/request/grpc-messages.component'; + +export class GrpcRequestPage extends BasePage { + readonly sidebar = new SidebarComponent(this.page); + readonly breadcrumb = new BreadcrumbComponent(this.page, 'grpc-request-breadcrumb'); + readonly urlBar = new RequestUrlBarComponent(this.page); + readonly messages = new GrpcMessagesComponent(this.page); + readonly snippet = new CodeSnippetComponent(this.page, 'grpc-request-code-snippet'); + readonly executionContext = new ExecutionContextComponent(this.page); + + readonly root: Locator = this.page.getByTestId('grpc-request-page'); + readonly title: Locator = this.page.getByTestId('grpc-request-title'); + readonly description: Locator = this.page.getByTestId('grpc-request-description'); + + readonly protoFileSection: Locator = this.page.getByTestId('grpc-request-section-proto-file'); + readonly protoFile: Locator = this.page.getByTestId('grpc-request-proto-file'); + + readonly methodSection: Locator = this.page.getByTestId('grpc-request-section-method'); + readonly method: Locator = this.page.getByTestId('grpc-request-method'); + + readonly messagesSection: Locator = this.page.getByTestId('grpc-request-section-messages'); + + readonly metadataSection: Locator = this.page.getByTestId('grpc-request-section-metadata'); + readonly metadata: Locator = this.page.getByTestId('grpc-request-metadata'); + + readonly authSection: Locator = this.page.getByTestId('grpc-request-section-auth'); + readonly auth: Locator = this.page.getByTestId('grpc-request-auth'); + readonly authInheritedBadge: Locator = this.page.getByTestId('grpc-request-auth-inherited'); + + readonly emptyState: Locator = this.page.getByTestId('grpc-request-config-empty'); + readonly codeSnippet: Locator = this.snippet.code; + + readonly executionContextSection: Locator = this.page.getByTestId('grpc-request-section-execution-context'); + readonly executionContextEmpty: Locator = this.page.getByTestId('grpc-request-execution-context-empty'); + + async open(path: string[]): Promise { + await this.navigate('/'); + await this.sidebar.open(path); + await this.root.waitFor({ state: 'visible' }); + } +} diff --git a/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts b/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts index 93a39f79..4547295d 100644 --- a/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts +++ b/packages/bruno-api-docs/e2e/playwright/pages.fixture.ts @@ -5,6 +5,7 @@ import { RequestPage } from '../pages/request.page'; import { ScriptPage } from '../pages/script.page'; import { FolderPage } from '../pages/folder.page'; import { UnsupportedRequestPage } from '../pages/unsupported-request.page'; +import { GrpcRequestPage } from '../pages/grpc-request.page'; import { SidebarComponent } from '../components/sidebar.component'; import { TooltipComponent } from '../components/tooltip.component'; import { PlaygroundComponent } from '../components/playground.component'; @@ -24,6 +25,7 @@ type Fixtures = { scriptPage: ScriptPage; folderPage: FolderPage; unsupportedRequestPage: UnsupportedRequestPage; + grpcRequestPage: GrpcRequestPage; sidebar: SidebarComponent; tooltip: TooltipComponent; playground: PlaygroundComponent; @@ -56,6 +58,9 @@ export const test = base.extend({ unsupportedRequestPage: async ({ page }, use) => { await use(new UnsupportedRequestPage(page)); }, + grpcRequestPage: async ({ page }, use) => { + await use(new GrpcRequestPage(page)); + }, sidebar: async ({ page }, use) => { await use(new SidebarComponent(page)); }, diff --git a/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts b/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts index 66f546f4..99c39221 100644 --- a/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts @@ -4,7 +4,7 @@ test.describe('Folder page', () => { test('displays the folder name and how many requests it contains', async ({ folderPage }) => { await folderPage.open(['Realtime']); await expect(folderPage.title).toHaveText('Realtime'); - await expect(folderPage.requestCount).toHaveText('3 requests'); + await expect(folderPage.requestCount).toHaveText('10 requests'); }); test('shows config inherited from the collection even when the folder has no own config', async ({ folderPage }) => { diff --git a/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts b/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts index 7cb616a8..02b414ad 100644 --- a/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts @@ -22,9 +22,9 @@ test.describe('Collection Overview', () => { }); }); - test('shows three stat cards with the request (41), folder (7) and environment (2) counts', async ({ overviewPage }) => { + test('shows three stat cards with the request (48), folder (7) and environment (2) counts', async ({ overviewPage }) => { await expect(overviewPage.stats.cards).toHaveCount(3); - await expect(overviewPage.stats.valueFor('Requests')).toHaveText('41'); + await expect(overviewPage.stats.valueFor('Requests')).toHaveText('48'); await expect(overviewPage.stats.valueFor('Folders')).toHaveText('7'); await expect(overviewPage.stats.valueFor('Environments')).toHaveText('2'); }); diff --git a/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts new file mode 100644 index 00000000..4c1bcb8b --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/request/grpc-request.spec.ts @@ -0,0 +1,199 @@ +import { test, expect } from '../../playwright'; + +const REALTIME = 'Realtime'; + +test.describe('Request page — gRPC requests', () => { + test('renders the request identity without offering to run it', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.title).toHaveText('Order Service'); + await expect(grpcRequestPage.breadcrumb.current).toHaveText('Order Service'); + await expect(grpcRequestPage.urlBar.method).toHaveText('gRPC'); + await expect(grpcRequestPage.urlBar.url).toContainText('grpcUrl'); + await expect(grpcRequestPage.urlBar.tryButton).toHaveCount(0); + }); + + test('marks the grpcurl command plaintext for an unencrypted environment', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.codeSnippet).toContainText('-plaintext'); + await expect(grpcRequestPage.codeSnippet).toContainText('{{grpcUrl}}'); + }); + + test('drops plaintext when the environment resolves the address to TLS', async ({ grpcRequestPage, envSwitcher }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await envSwitcher.selectEnvironment('Prod'); + + await expect(grpcRequestPage.codeSnippet).not.toContainText('-plaintext'); + await expect(grpcRequestPage.codeSnippet).toContainText('{{grpcUrl}}'); + }); + + test('renders the request docs when the request provides them', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.description).toContainText('Fetches a single order by id over gRPC'); + }); + + test('shows the method with its streaming type', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Send Greetings']); + + await expect(grpcRequestPage.method).toContainText('hello.HelloService/LotsOfGreetings'); + await expect(grpcRequestPage.method).not.toContainText('/hello.HelloService'); + await expect(grpcRequestPage.method).toContainText('Client Streaming'); + }); + + test('shows the proto file name when one is attached', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Get Book']); + + await expect(grpcRequestPage.protoFile).toHaveText('book.proto'); + }); + + test('omits the proto file section when the request uses reflection', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.protoFileSection).toHaveCount(0); + await expect(grpcRequestPage.methodSection).toBeVisible(); + }); + + test('lists metadata with its descriptions and marks disabled rows', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.metadata).toContainText('authorization'); + await expect(grpcRequestPage.metadata).toContainText('Auth token forwarded to the service'); + await expect(grpcRequestPage.metadata).toContainText('x-legacy-flag'); + await expect(grpcRequestPage.metadata.getByTestId('disabled-badge')).toBeVisible(); + await expect(grpcRequestPage.metadataSection).toContainText('2 fields'); + }); + + test('resolves inherited auth and names where it came from', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + + await expect(grpcRequestPage.authInheritedBadge).toHaveText(`Inherited from folder: ${REALTIME}`); + await expect(grpcRequestPage.auth).toContainText('No auth'); + }); + + test('navigates to the folder the auth came from', async ({ grpcRequestPage, folderPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.authInheritedBadge.click(); + + await expect(folderPage.root).toBeVisible(); + await expect(folderPage.title).toHaveText(REALTIME); + }); + + test('shows concrete auth with its secret masked', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Get Book']); + + await expect(grpcRequestPage.auth).toContainText('Basic Auth'); + await expect(grpcRequestPage.auth).toContainText('reader'); + await expect(grpcRequestPage.auth).not.toContainText('s3cret'); + }); + + test('omits the auth section when the request has none', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Chat']); + + await expect(grpcRequestPage.authSection).toHaveCount(0); + }); + + test('shows one empty state when the request has no configuration', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Bare Method']); + + await expect(grpcRequestPage.emptyState).toContainText('No request configuration'); + await expect(grpcRequestPage.methodSection).toHaveCount(0); + await expect(grpcRequestPage.messagesSection).toHaveCount(0); + }); +}); + +test.describe('Request page — gRPC messages', () => { + test('opens the first message and leaves the rest closed', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Send Greetings']); + + await expect(grpcRequestPage.messages.toggle(0)).toHaveAttribute('aria-expanded', 'true'); + await expect(grpcRequestPage.messages.toggle(1)).toHaveAttribute('aria-expanded', 'false'); + await expect(grpcRequestPage.messages.code(0)).toBeVisible(); + }); + + test('offers no show-more control when every message already fits', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Send Greetings']); + + await expect(grpcRequestPage.messages.showToggle).toHaveCount(0); + }); + + test('collapses a message that was open', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Send Greetings']); + await grpcRequestPage.messages.toggle(0).click(); + + await expect(grpcRequestPage.messages.toggle(0)).toHaveAttribute('aria-expanded', 'false'); + }); + + test('shows only the first three messages until show more is used', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Bulk Upload']); + + await expect(grpcRequestPage.messagesSection).toContainText('6 messages'); + await expect(grpcRequestPage.messages.card(2)).toBeVisible(); + await expect(grpcRequestPage.messages.card(3)).toHaveCount(0); + await expect(grpcRequestPage.messages.showToggle).toHaveText('Show more'); + + await grpcRequestPage.messages.showToggle.click(); + + await expect(grpcRequestPage.messages.card(5)).toBeVisible(); + await expect(grpcRequestPage.messages.showToggle).toHaveText('Show less'); + }); + + test('keeps every expanded message open across show more and show less', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Bulk Upload']); + + await grpcRequestPage.messages.toggle(1).click(); + await grpcRequestPage.messages.toggle(2).click(); + await grpcRequestPage.messages.showToggle.click(); + await grpcRequestPage.messages.toggle(4).click(); + await grpcRequestPage.messages.showToggle.click(); + + await expect(grpcRequestPage.messages.card(3)).toHaveCount(0); + await expect(grpcRequestPage.messages.toggle(1)).toHaveAttribute('aria-expanded', 'true'); + await expect(grpcRequestPage.messages.toggle(2)).toHaveAttribute('aria-expanded', 'true'); + + await grpcRequestPage.messages.showToggle.click(); + + await expect(grpcRequestPage.messages.toggle(4)).toHaveAttribute('aria-expanded', 'true'); + }); +}); + +test.describe('Request page — gRPC execution context', () => { + test('lists the variables the request defines', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.executionContext.openTab('variables'); + + await expect(grpcRequestPage.executionContext.variable('orderId')).toBeVisible(); + await expect(grpcRequestPage.executionContext.variable('lastOrderStatus')).toBeVisible(); + }); + + test('lists the scripts that run around the call', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.executionContext.openTab('scripts'); + + await expect(grpcRequestPage.executionContext.scriptStep('Request Pre-Request')).toBeVisible(); + await expect(grpcRequestPage.executionContext.scriptStep('Request Post-Response')).toBeVisible(); + }); + + test('lists the assertions the request declares', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.executionContext.openTab('asserts'); + + await expect(grpcRequestPage.executionContext.assertion('res.body.orderId')).toBeVisible(); + }); + + test('lists the tests the request declares', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Order Service']); + await grpcRequestPage.executionContext.openTab('tests'); + + await expect(grpcRequestPage.executionContext.testsPanel).toContainText('returns the requested order'); + }); + + test('still shows the inherited chain for a request with no runtime of its own', async ({ grpcRequestPage }) => { + await grpcRequestPage.open([REALTIME, 'Chat']); + await grpcRequestPage.executionContext.openTab('scripts'); + + await expect(grpcRequestPage.executionContext.scriptStep('Collection Pre-Request')).toBeVisible(); + await expect(grpcRequestPage.executionContext.scriptStep('Request Pre-Request')).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/request/request-examples.spec.ts b/packages/bruno-api-docs/e2e/tests/request/request-examples.spec.ts index 3782b2f5..c883dc74 100644 --- a/packages/bruno-api-docs/e2e/tests/request/request-examples.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/request-examples.spec.ts @@ -128,7 +128,7 @@ test.describe('Request page — Examples', () => { const { examples } = requestPage; await examples.openSnippet(OK_EXAMPLE); await expect(page.getByRole('dialog')).toHaveCount(1); - await expect(examples.snippetModal.getByTestId('code-snippet-expand')).toHaveCount(0); + await expect(examples.snippetModal.getByTestId('example-code-snippet-expand')).toHaveCount(0); }); test('dismisses on Escape and returns focus to the trigger', async ({ requestPage, page }) => { diff --git a/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts b/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts index 3ee25718..209e5ae1 100644 --- a/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/request/unsupported-request.spec.ts @@ -2,8 +2,7 @@ import { test, expect } from '../../playwright'; const UNSUPPORTED_REQUESTS = [ { paths: ['Realtime', 'Live Updates'], name: 'Live Updates', typeLabel: 'Websocket', shortName: 'WS', url: '/ws/updates' }, - { paths: ['Realtime', 'GraphQL API'], name: 'GraphQL API', typeLabel: 'GraphQL', shortName: 'GQL', url: '/graphql' }, - { paths: ['Realtime', 'Order Service'], name: 'Order Service', typeLabel: 'gRPC', shortName: 'GRPC', url: '/orders.OrderService' } + { paths: ['Realtime', 'GraphQL API'], name: 'GraphQL API', typeLabel: 'GraphQL', shortName: 'GQL', url: '/graphql' } ]; test.describe('Request page — unsupported request types', () => { diff --git a/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx b/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx new file mode 100644 index 00000000..2af4abdd --- /dev/null +++ b/packages/bruno-api-docs/src/assets/icons/BidiStreamingIcon.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +export const BidiStreamingIcon: React.FC<{ size?: number }> = ({ size = 20 }) => ( + + + + +); + +export default BidiStreamingIcon; diff --git a/packages/bruno-api-docs/src/assets/icons/ChevronDownIcon.tsx b/packages/bruno-api-docs/src/assets/icons/ChevronDownIcon.tsx index 108b99af..eb03ad0b 100644 --- a/packages/bruno-api-docs/src/assets/icons/ChevronDownIcon.tsx +++ b/packages/bruno-api-docs/src/assets/icons/ChevronDownIcon.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { baseIconProps } from './baseIconProps'; -/** Chevron down, the search palette's folder filter dropdown. */ -export const ChevronDownIcon: React.FC = () => ( - +/** Chevron down, the search palette's folder filter dropdown and expand toggles. */ +export const ChevronDownIcon: React.FC<{ size?: number; className?: string }> = ({ size = 16, className }) => ( + ); + +export default ChevronDownIcon; diff --git a/packages/bruno-api-docs/src/assets/icons/ClientStreamingIcon.tsx b/packages/bruno-api-docs/src/assets/icons/ClientStreamingIcon.tsx new file mode 100644 index 00000000..34b4c236 --- /dev/null +++ b/packages/bruno-api-docs/src/assets/icons/ClientStreamingIcon.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +export const ClientStreamingIcon: React.FC<{ size?: number }> = ({ size = 20 }) => ( + + + +); + +export default ClientStreamingIcon; diff --git a/packages/bruno-api-docs/src/assets/icons/ServerStreamingIcon.tsx b/packages/bruno-api-docs/src/assets/icons/ServerStreamingIcon.tsx new file mode 100644 index 00000000..7f661550 --- /dev/null +++ b/packages/bruno-api-docs/src/assets/icons/ServerStreamingIcon.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +export const ServerStreamingIcon: React.FC<{ size?: number }> = ({ size = 20 }) => ( + + + + +); + +export default ServerStreamingIcon; diff --git a/packages/bruno-api-docs/src/assets/icons/UnaryIcon.tsx b/packages/bruno-api-docs/src/assets/icons/UnaryIcon.tsx new file mode 100644 index 00000000..1c17cb50 --- /dev/null +++ b/packages/bruno-api-docs/src/assets/icons/UnaryIcon.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { baseIconProps } from './baseIconProps'; + +export const UnaryIcon: React.FC<{ size?: number }> = ({ size = 20 }) => ( + + + +); + +export default UnaryIcon; diff --git a/packages/bruno-api-docs/src/assets/icons/index.ts b/packages/bruno-api-docs/src/assets/icons/index.ts index ca193964..dd123ba7 100644 --- a/packages/bruno-api-docs/src/assets/icons/index.ts +++ b/packages/bruno-api-docs/src/assets/icons/index.ts @@ -31,3 +31,7 @@ export * from './ExampleIcon'; export * from './DotIcon'; export * from './ChevronsRightIcon'; export * from './CheckIcon'; +export * from './UnaryIcon'; +export * from './ServerStreamingIcon'; +export * from './ClientStreamingIcon'; +export * from './BidiStreamingIcon'; diff --git a/packages/bruno-api-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx b/packages/bruno-api-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx index f24015be..0c6c908d 100644 --- a/packages/bruno-api-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx +++ b/packages/bruno-api-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx @@ -1,22 +1,14 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useMemo } from 'react'; import type { HttpRequestBody, HttpRequestBodyVariant, HttpRequestHeader } from '@opencollection/types/requests/http'; import type { Auth } from '@opencollection/types/common/auth'; -import { Code } from '../Code/Code'; -import { CopyButton } from '../../ui/CopyButton/CopyButton'; -import { useResolvedVariables } from '../../hooks'; -import { SectionLabel } from '../SectionLabel/SectionLabel'; -import { Modal } from '../../ui/Modal/Modal'; -import { ExpandIcon } from '../../assets/icons'; +import { SnippetTabs, type Snippet } from '../SnippetTabs/SnippetTabs'; import { generateCurlCommand, generateJavaScriptCode, generatePythonCode, type SnippetHeader, type SnippetInput -} from '../../utils/codeSnippets'; -import { StyledWrapper } from './StyledWrapper'; -import { IconCode } from '@tabler/icons'; -import { cx } from '@/utils/cx'; +} from '@/utils/codeSnippets'; interface CodeSnippetTabsProps { method: string; @@ -35,13 +27,16 @@ const LANGUAGES = [ { id: 'python', label: 'Python', language: 'python', generate: generatePythonCode } ] as const; -export const CodeSnippetTabs: React.FC = ({ method, url, headers, body, auth, variant = 'inline', className, testId = 'request-code-snippet' }) => { - const [active, setActive] = useState(LANGUAGES[0].id); - const [modalActive, setModalActive] = useState(LANGUAGES[0].id); - const [expanded, setExpanded] = useState(false); - const triggerRef = useRef(null); - const { showVars, resolve } = useResolvedVariables(); - +export const CodeSnippetTabs: React.FC = ({ + method, + url, + headers, + body, + auth, + variant = 'inline', + className, + testId +}) => { const snippetHeaders: SnippetHeader[] = useMemo( () => (headers ?? []) @@ -50,97 +45,17 @@ export const CodeSnippetTabs: React.FC = ({ method, url, h [headers] ); - const snippets = useMemo(() => { + const snippets: Snippet[] = useMemo(() => { const input: SnippetInput = { method, url, headers: snippetHeaders, body, auth }; - return LANGUAGES.reduce>((acc, lang) => { - acc[lang.id] = lang.generate(input); - return acc; - }, {}); + return LANGUAGES.map((lang) => ({ + id: lang.id, + label: lang.label, + language: lang.language, + code: lang.generate(input) + })); }, [method, url, snippetHeaders, body, auth]); - const openModal = () => { - setModalActive(active); - setExpanded(true); - }; - - const closeModal = () => { - setExpanded(false); - triggerRef.current?.focus(); - }; - - const renderSnippetBox = ( - placement: 'inline' | 'modal', - activeId: string, - setActiveId: (id: string) => void - ) => { - const activeLang = LANGUAGES.find((lang) => lang.id === activeId) ?? LANGUAGES[0]; - const snippet = snippets[activeId]; - const copyText = showVars ? resolve(snippet) : snippet; - return ( -
-
-
- {LANGUAGES.map((lang) => ( - - ))} -
- - {placement === 'inline' ? ( - - ) : ( - - )} -
- -
- ); - }; - - return ( - - {variant === 'inline' ? ( - renderSnippetBox('inline', active, setActive) - ) : ( - - )} - Code snippet} ariaLabel="Code snippet"> - {expanded && ( - - {renderSnippetBox('modal', modalActive, setModalActive)} - - )} - - - ); + return ; }; export default CodeSnippetTabs; diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx index 7d7670a8..92cfbdf4 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx @@ -29,7 +29,7 @@ interface ExecutionContextProps { tests: TestRow[]; testScripts?: RawTestScript[]; flow?: ScriptFlow; - method?: string; + requestLabel?: string; url?: string; variant?: 'tabs' | 'docs'; className?: string; @@ -65,7 +65,7 @@ export const ExecutionContext: React.FC = ({ tests, testScripts = [], flow = 'sandwich', - method, + requestLabel, url, variant = 'tabs', className, @@ -88,7 +88,9 @@ export const ExecutionContext: React.FC = ({ const inheritedVarCount = inheritedPreVars.length + inheritedPostVars.length; const inheritedVarsBadge = inheritedVarCount > 0 ? : undefined; - const scripts = ; + const scripts = ( + + ); const variables = ( void; } -const HttpMarker: React.FC<{ position: number; url?: string }> = ({ position, url }) => ( +const RequestMarker: React.FC<{ position: number; label: string; url?: string }> = ({ position, label, url }) => (
{position}
); -export const ScriptChain: React.FC = ({ steps, flow, url, onNavigate }) => { +export const ScriptChain: React.FC = ({ steps, flow, requestLabel = 'HTTP', url, onNavigate }) => { const { pre, post } = useMemo(() => { const byOrderAsc = (a: ScriptChainStep, b: ScriptChainStep) => a.order - b.order; const pre = steps.filter((s) => s.phase === 'before-request').sort(byOrderAsc); @@ -49,7 +49,7 @@ export const ScriptChain: React.FC = ({ steps, flow, url, onNa {pre.map((step, index) => ( ))} - + {post.map((step, index) => ( ))} diff --git a/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx new file mode 100644 index 00000000..fa503c44 --- /dev/null +++ b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, query } from '@/test-utils/dom'; +import { ExpandToggle } from './ExpandToggle'; + +const noop = () => {}; + +describe('ExpandToggle', () => { + it('shows the more label while collapsed', () => { + const root = useRenderToDom( + + ); + + const button = getByTestId(root, 't'); + expect(button.text).toContain('Show more'); + expect(button.text).not.toContain('Show less'); + expect(button.attributes['aria-expanded']).toBe('false'); + }); + + it('swaps to the less label while expanded', () => { + const root = useRenderToDom( + + ); + + const button = getByTestId(root, 't'); + expect(button.text).toContain('Show less'); + expect(button.attributes['aria-expanded']).toBe('true'); + }); + + it('is a button so it is reachable by keyboard', () => { + const root = useRenderToDom( + + ); + + const button = getByTestId(root, 't'); + expect(button.tagName.toLowerCase()).toBe('button'); + expect(button.attributes.type).toBe('button'); + }); + + it('points at the region it controls when given one', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 't').attributes['aria-controls']).toBe('panel-1'); + }); + + it('omits aria-controls when there is no region to name', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 't').attributes['aria-controls']).toBeUndefined(); + }); + + it('hides the chevron from assistive tech and keeps the caller class', () => { + const root = useRenderToDom( + + ); + + const button = getByTestId(root, 't'); + expect(button.attributes.class).toContain('expand-toggle'); + expect(button.attributes.class).toContain('mt-3'); + expect(query(root, '.expand-toggle-chevron').attributes['aria-hidden']).toBe('true'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.tsx b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.tsx new file mode 100644 index 00000000..9ff1e9b0 --- /dev/null +++ b/packages/bruno-api-docs/src/components/ExpandToggle/ExpandToggle.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import cx from '@/utils/cx'; +import { ChevronDownIcon } from '@/assets/icons'; +import { StyledWrapper } from './StyledWrapper'; + +interface ExpandToggleProps { + expanded: boolean; + moreLabel: string; + lessLabel: string; + onToggle: () => void; + controls?: string; + className?: string; + testId?: string; +} + +export const ExpandToggle: React.FC = ({ + expanded, + moreLabel, + lessLabel, + onToggle, + controls, + className, + testId +}) => ( + + {expanded ? lessLabel : moreLabel} + + +); + +export default ExpandToggle; diff --git a/packages/bruno-api-docs/src/components/ExpandToggle/StyledWrapper.ts b/packages/bruno-api-docs/src/components/ExpandToggle/StyledWrapper.ts new file mode 100644 index 00000000..f17c9312 --- /dev/null +++ b/packages/bruno-api-docs/src/components/ExpandToggle/StyledWrapper.ts @@ -0,0 +1,37 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.button` + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0; + border: none; + background: none; + cursor: pointer; + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.8125rem; + line-height: 1; + color: var(--primary-text); + + .expand-toggle-chevron { + flex-shrink: 0; + transition: transform 0.15s ease; + } + + &[aria-expanded='true'] .expand-toggle-chevron { + transform: rotate(180deg); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 2px; + } + + @media (prefers-reduced-motion: reduce) { + .expand-toggle-chevron { + transition: none; + } + } +`; diff --git a/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx b/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx index a7d2c21a..f1cbbcbb 100644 --- a/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx +++ b/packages/bruno-api-docs/src/components/MethodBadge/MethodBadge.tsx @@ -1,21 +1,23 @@ import React from 'react'; -import cx from '../../utils/cx'; -import { getMethodColorVar } from '../../theme/methodColors'; +import cx from '@/utils/cx'; +import { getMethodColorVar } from '@/theme/methodColors'; import { StyledWrapper } from './StyledWrapper'; interface MethodBadgeProps { method: string; className?: string; + capitalizeMethod?: boolean; } -export const MethodBadge: React.FC = ({ method, className }) => { +export const MethodBadge: React.FC = ({ method, className, capitalizeMethod = true }) => { const resolvedMethod = method || 'GET'; + return ( - {resolvedMethod.toUpperCase()} + {capitalizeMethod ? resolvedMethod.toUpperCase() : resolvedMethod} ); }; diff --git a/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts b/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts index da75792e..223de02d 100644 --- a/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/MethodBadge/StyledWrapper.ts @@ -7,5 +7,8 @@ export const StyledWrapper = styled.span` font-weight: 700; font-size: 0.75rem; letter-spacing: 0.02em; - text-transform: uppercase; + + &.method-badge--as-written { + text-transform: none; + } `; diff --git a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx index 1b47a8af..21c62f34 100644 --- a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx +++ b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useRef } from 'react'; import { Navigate } from 'react-router-dom'; -import type { HttpRequest } from '@opencollection/types/requests/http'; import type { ScriptFile, Folder as FolderItem } from '@opencollection/types/collection/item'; +import type { RequestItem } from '../../utils/schemaHelpers'; import { useActiveResolution, useNavModel } from '../../routing/hooks'; import { useAppSelector } from '../../store/hooks'; import { selectDocsCollection } from '../../store/slices/docs'; @@ -93,7 +93,7 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag return item ? ( = ({ item, collec }; const PlaygroundView: React.FC = ({ item, ...otherProps }) => { - if (isUnsupportedRequest(item)) { + if (isUnsupportedRequestInPlayground(item)) { return ( void; tryLabel?: string; @@ -18,6 +19,7 @@ interface RequestUrlBarProps { export const RequestUrlBar: React.FC = ({ method, + capitalizeMethod = true, url, onTry, tryLabel = 'Try', @@ -27,7 +29,7 @@ export const RequestUrlBar: React.FC = ({ }) => ( - + diff --git a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx new file mode 100644 index 00000000..0adc3473 --- /dev/null +++ b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.spec.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import { SnippetTabs, type Snippet } from './SnippetTabs'; + +const snippets: Snippet[] = [ + { id: 'grpcurl', label: 'grpcURL', language: 'bash', code: 'grpcurl -plaintext {{host}} pkg.Svc/Do' }, + { id: 'javascript', label: 'JavaScript', language: 'javascript', code: 'const grpc = require(\'@grpc/grpc-js\');' } +]; + +describe('SnippetTabs', () => { + it('renders a tab per snippet and shows the first one', () => { + const root = useRenderToDom(); + + expect(getByTestId(root, 'request-code-snippet-tab-grpcurl').text).toBe('grpcURL'); + expect(getByTestId(root, 'request-code-snippet-tab-javascript').text).toBe('JavaScript'); + const code = getByTestId(root, 'request-code-snippet-code'); + expect(code.text).toContain('pkg.Svc/Do'); + expect(code.text).not.toContain('@grpc/grpc-js'); + }); + + it('renders nothing when there are no snippets', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'request-code-snippet')).toBeNull(); + }); + + it('derives every child test id from the testId it is given', () => { + const root = useRenderToDom(); + + expect(queryByTestId(root, 'grpc-request-code-snippet')).not.toBeNull(); + expect(queryByTestId(root, 'grpc-request-code-snippet-tab-grpcurl')).not.toBeNull(); + expect(queryByTestId(root, 'grpc-request-code-snippet-tab-javascript')).not.toBeNull(); + expect(queryByTestId(root, 'grpc-request-code-snippet-expand')).not.toBeNull(); + expect(queryByTestId(root, 'grpc-request-code-snippet-code')).not.toBeNull(); + }); + + it('falls back to the request base when no testId is given', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'request-code-snippet-tab-grpcurl')).not.toBeNull(); + }); + + it('marks the active tab as selected', () => { + const root = useRenderToDom(); + + expect(getByTestId(root, 'request-code-snippet-tab-grpcurl').attributes['aria-selected']).toBe('true'); + expect(getByTestId(root, 'request-code-snippet-tab-javascript').attributes['aria-selected']).toBe('false'); + }); + + it('collapses to a trigger instead of the code box when embedded', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'example-code-snippet-trigger').text).toContain('Code Snippet'); + expect(queryByTestId(root, 'example-code-snippet-code')).toBeNull(); + expect(queryByTestId(root, 'example-code-snippet-expand')).toBeNull(); + }); + + it('renders variables in the code as hover tokens', () => { + const root = useRenderToDom(); + + const code = getByTestId(root, 'request-code-snippet-code'); + const token = code.querySelector('[data-var-name="host"]'); + expect(token).not.toBeNull(); + expect(code.text).toContain('{{host}}'); + }); + + it('passes the snippet language through to the highlighter', () => { + const root = useRenderToDom( + + ); + + const code = getByTestId(root, 'request-code-snippet-code'); + expect(code.querySelector('code.language-json')).not.toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx new file mode 100644 index 00000000..9639ac44 --- /dev/null +++ b/packages/bruno-api-docs/src/components/SnippetTabs/SnippetTabs.tsx @@ -0,0 +1,134 @@ +import React, { useRef, useState } from 'react'; +import { IconCode } from '@tabler/icons'; +import cx from '@/utils/cx'; +import { Code } from '../Code/Code'; +import { CopyButton } from '@/ui/CopyButton/CopyButton'; +import { useResolvedVariables } from '@/hooks'; +import { SectionLabel } from '../SectionLabel/SectionLabel'; +import { Modal } from '@/ui/Modal/Modal'; +import { ExpandIcon } from '@/assets/icons'; +import { StyledWrapper } from './StyledWrapper'; + +export interface Snippet { + id: string; + label: string; + language: string; + code: string; +} + +interface SnippetTabsProps { + snippets: Snippet[]; + variant?: 'inline' | 'embedded'; + className?: string; + testId?: string; +} + +export const SnippetTabs: React.FC = ({ + snippets, + variant = 'inline', + className, + testId = 'request-code-snippet' +}) => { + const [active, setActive] = useState(snippets[0]?.id ?? ''); + const [activeModalId, setActiveModalId] = useState(snippets[0]?.id ?? ''); + const [expanded, setExpanded] = useState(false); + const triggerRef = useRef(null); + const { showVars, resolve } = useResolvedVariables(); + + if (snippets.length === 0) return null; + + const openModal = () => { + setActiveModalId(active); + setExpanded(true); + }; + + const closeModal = () => { + setExpanded(false); + triggerRef.current?.focus(); + }; + + const renderSnippetBox = (placement: 'inline' | 'modal', activeId: string, setActiveId: (id: string) => void) => { + const activeSnippet = snippets.find((snippet) => snippet.id === activeId) ?? snippets[0]; + const code = activeSnippet.code; + const copyText = showVars ? resolve(code) : code; + return ( +
+
+
+ {snippets.map((snippet) => ( + + ))} +
+ + {placement === 'inline' ? ( + + ) : ( + + )} +
+ +
+ ); + }; + + return ( + + {variant === 'inline' ? ( + renderSnippetBox('inline', active, setActive) + ) : ( + + )} + Code snippet} + ariaLabel="Code snippet" + > + {expanded && ( + + {renderSnippetBox('modal', activeModalId, setActiveModalId)} + + )} + + + ); +}; + +export default SnippetTabs; diff --git a/packages/bruno-api-docs/src/components/CodeSnippetTabs/StyledWrapper.ts b/packages/bruno-api-docs/src/components/SnippetTabs/StyledWrapper.ts similarity index 87% rename from packages/bruno-api-docs/src/components/CodeSnippetTabs/StyledWrapper.ts rename to packages/bruno-api-docs/src/components/SnippetTabs/StyledWrapper.ts index a32bd367..9c695b9b 100644 --- a/packages/bruno-api-docs/src/components/CodeSnippetTabs/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/SnippetTabs/StyledWrapper.ts @@ -85,6 +85,21 @@ export const StyledWrapper = styled.div` outline-offset: 2px; } + .snippet-box .code-content-numbered, + .snippet-box .code-content:not(.code-content--numbered) { + max-height: calc(100vh - 12rem); + overflow-y: auto; + } + + .snippet-box .code-content-numbered { + align-items: flex-start; + } + + &.is-modal .snippet-box .code-content-numbered, + &.is-modal .snippet-box .code-content:not(.code-content--numbered) { + max-height: none; + } + .snippet-copy { align-self: center; flex: 0 0 auto; diff --git a/packages/bruno-api-docs/src/components/ViewMore/StyledWrapper.ts b/packages/bruno-api-docs/src/components/ViewMore/StyledWrapper.ts index 06e1ca6a..e67f02ff 100644 --- a/packages/bruno-api-docs/src/components/ViewMore/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/ViewMore/StyledWrapper.ts @@ -23,37 +23,11 @@ export const StyledWrapper = styled.div` &.is-animating .view-more-content { transition: none; } - .view-more-chevron { - transition: none; - } } .view-more-toggle { - display: inline-flex; - align-items: center; - gap: 0.25rem; margin-top: 0.75rem; - padding: 0; - border: none; - background: none; - cursor: pointer; - font-family: var(--font-sans); - font-weight: 500; - font-size: 0.8125rem; - line-height: 1; letter-spacing: 0; color: var(--primary-text); } - .view-more-chevron { - flex-shrink: 0; - transition: transform 0.15s ease; - } - .view-more-toggle[aria-expanded='true'] .view-more-chevron { - transform: rotate(180deg); - } - .view-more-toggle:focus-visible { - outline: 2px solid var(--primary-color); - outline-offset: 2px; - border-radius: 2px; - } `; diff --git a/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx b/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx index 0dbf6b3d..6cdad932 100644 --- a/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx +++ b/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useId, useRef, useState } from 'react'; import { prefersReducedMotion } from '../../utils/motion'; +import { ExpandToggle } from '../ExpandToggle/ExpandToggle'; import { StyledWrapper } from './StyledWrapper'; interface ViewMoreProps { @@ -99,30 +100,15 @@ export const ViewMore: React.FC = ({ {children}
{overflowing && ( - + testId={testId && `${testId}-toggle`} + /> )} ); diff --git a/packages/bruno-api-docs/src/constants/index.ts b/packages/bruno-api-docs/src/constants/index.ts index aae92b31..b74f3850 100644 --- a/packages/bruno-api-docs/src/constants/index.ts +++ b/packages/bruno-api-docs/src/constants/index.ts @@ -15,7 +15,8 @@ export { PROTOCOL_BADGE_LABELS, REQUEST_TYPE_LABELS, BODY_LANGUAGE, - BODY_CONTENT_TYPE + BODY_CONTENT_TYPE, + GRPC_METHOD_TYPE_LABELS } from './request'; export { RESPONSE_LANGUAGE, RESPONSE_CONTENT_TYPE, STATUS_CODE_PHRASES } from './response'; diff --git a/packages/bruno-api-docs/src/constants/request.ts b/packages/bruno-api-docs/src/constants/request.ts index 00b0e006..05d96089 100644 --- a/packages/bruno-api-docs/src/constants/request.ts +++ b/packages/bruno-api-docs/src/constants/request.ts @@ -37,6 +37,13 @@ export const STANDARD_HTTP_METHODS = [ 'CONNECT' ] as const; +export const GRPC_METHOD_TYPE_LABELS: Record = { + 'unary': 'Unary', + 'client-streaming': 'Client Streaming', + 'server-streaming': 'Server Streaming', + 'bidi-streaming': 'Bidirectional Streaming' +}; + export const PROTOCOL_BADGE_LABELS: Record = { GRAPHQL: 'GQL', GRPC: 'GRPC', diff --git a/packages/bruno-api-docs/src/hooks/useRenderToDom.ts b/packages/bruno-api-docs/src/hooks/useRenderToDom.ts index 58068275..60c68ff1 100644 --- a/packages/bruno-api-docs/src/hooks/useRenderToDom.ts +++ b/packages/bruno-api-docs/src/hooks/useRenderToDom.ts @@ -3,7 +3,9 @@ import { parse } from 'node-html-parser'; import type { ReactElement } from 'react'; export const useRenderToDom = (ui: ReactElement) => { - const root = parse(renderToStaticMarkup(ui)); + const root = parse(renderToStaticMarkup(ui), { + blockTextElements: { script: true, noscript: true, style: true } + }); root.querySelectorAll('style').forEach((node) => node.remove()); return root; }; diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx new file mode 100644 index 00000000..e8e15abb --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.spec.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import { GrpcMessageCard } from './GrpcMessageCard'; + +describe('GrpcMessageCard', () => { + it('renders the title and the message when expanded', () => { + const root = useRenderToDom( + {}} /> + ); + + expect(getByTestId(root, 'grpc-message-card-title').text).toBe('Message 1'); + expect(getByTestId(root, 'grpc-message-card-code').text).toContain('SKU-1001'); + expect(getByTestId(root, 'grpc-message-card-toggle').attributes['aria-expanded']).toBe('true'); + }); + + it('renders the title but not the message when collapsed', () => { + const root = useRenderToDom( + {}} /> + ); + + expect(getByTestId(root, 'grpc-message-card-title').text).toBe('Message 2'); + expect(queryByTestId(root, 'grpc-message-card-code')).toBeNull(); + expect(getByTestId(root, 'grpc-message-card-toggle').attributes['aria-expanded']).toBe('false'); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx new file mode 100644 index 00000000..584dae86 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/GrpcMessageCard.tsx @@ -0,0 +1,81 @@ +import React, { useId, useState } from 'react'; +import cx from '@/utils/cx'; +import { prefersReducedMotion } from '@/utils/motion'; +import { ChevronArrow } from '@/components/ChevronArrow/ChevronArrow'; +import { Code } from '@/components/Code/Code'; +import { StyledWrapper } from './StyledWrapper'; + +interface GrpcMessageCardProps { + title: string; + message: string; + expanded: boolean; + onToggle: () => void; + testId?: string; +} + +export const GrpcMessageCard: React.FC = ({ + title, + message, + expanded, + onToggle, + testId = 'grpc-message-card' +}) => { + const [collapsing, setCollapsing] = useState(false); + const detailId = useId(); + + const isOpen = expanded && !collapsing; + + const finishCollapse = () => { + setCollapsing(false); + onToggle(); + }; + + const handleToggle = () => { + if (collapsing) { + setCollapsing(false); + return; + } + if (!expanded || prefersReducedMotion()) { + onToggle(); + return; + } + setCollapsing(true); + }; + + const handleTransitionEnd = (event: React.TransitionEvent) => { + if (!collapsing) return; + if (event.propertyName !== 'grid-template-rows') return; + if (event.target !== event.currentTarget) return; + finishCollapse(); + }; + + return ( + +
+ +
+ +
+
+ {expanded && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default GrpcMessageCard; diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/StyledWrapper.ts new file mode 100644 index 00000000..817c46f1 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessageCard/StyledWrapper.ts @@ -0,0 +1,73 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + border: 1px solid var(--border-color); + border-radius: var(--oc-radius); + overflow: hidden; + background: var(--oc-background-base); + + &:not(:first-of-type) { + margin-top: 0.75rem; + } + + .grpc-message-summary { + display: flex; + align-items: center; + padding: 0.5rem; + } + + .grpc-message-toggle { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0; + margin: 0; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + } + .grpc-message-toggle:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; + } + + .grpc-message-chevron { + flex: 0 0 auto; + color: var(--text-muted); + } + + .grpc-message-title { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.125rem; + color: var(--text-primary); + } + + .grpc-message-detail { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; + } + .grpc-message-detail.is-open { + grid-template-rows: 1fr; + } + .grpc-message-detail-clip { + overflow: hidden; + min-height: 0; + } + .grpc-message-detail-body { + border-top: 1px solid var(--border-color); + } + + @media (prefers-reduced-motion: reduce) { + .grpc-message-detail { + transition: none; + } + } +`; diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.spec.tsx new file mode 100644 index 00000000..83e887cd --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import { GrpcMessages } from './GrpcMessages'; + +const entries = (count: number) => + Array.from({ length: count }, (_, index) => ({ + title: `Message ${index + 1}`, + message: `{"payload":"body-${index + 1}"}` + })); + +describe('GrpcMessages', () => { + it('renders nothing when there are no messages', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'grpc-messages')).toBeNull(); + }); + + it('opens the first message and leaves the rest closed', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'grpc-messages-card-0-code').text).toContain('body-1'); + expect(queryByTestId(root, 'grpc-messages-card-1-code')).toBeNull(); + expect(queryByTestId(root, 'grpc-messages-card-2-code')).toBeNull(); + }); + + it('shows only the first three messages and offers to show more', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'grpc-messages-card-2-title').text).toBe('Message 3'); + expect(queryByTestId(root, 'grpc-messages-card-3')).toBeNull(); + expect(getByTestId(root, 'grpc-messages-show-toggle').text).toContain('Show more'); + }); + + it('offers no show-more control when everything already fits', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'grpc-messages-show-toggle')).toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.tsx new file mode 100644 index 00000000..b82a4079 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMessages/GrpcMessages.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import type { GrpcMessageEntry } from '@/utils/schemaHelpers'; +import { GrpcMessageCard } from './GrpcMessageCard/GrpcMessageCard'; +import { ExpandToggle } from '@/components/ExpandToggle/ExpandToggle'; + +const COLLAPSED_COUNT = 3; + +interface GrpcMessagesProps { + messages: GrpcMessageEntry[]; + testId?: string; +} + +export const GrpcMessages: React.FC = ({ messages, testId = 'grpc-messages' }) => { + const [expandedIndexes, setExpandedIndexes] = useState>(() => new Set([0])); + const [showAll, setShowAll] = useState(false); + + if (messages.length === 0) return null; + + const visible = showAll ? messages : messages.slice(0, COLLAPSED_COUNT); + const hasOverflow = messages.length > COLLAPSED_COUNT; + + const toggle = (index: number) => { + setExpandedIndexes((previous) => { + const next = new Set(previous); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + return ( +
+ {visible.map((entry, index) => ( + toggle(index)} + testId={`${testId}-card-${index}`} + /> + ))} + + {hasOverflow && ( + setShowAll((value) => !value)} + className="mt-3" + testId={`${testId}-show-toggle`} + /> + )} +
+ ); +}; + +export default GrpcMessages; diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.spec.tsx new file mode 100644 index 00000000..bccdde80 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.spec.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import type { HTMLElement } from 'node-html-parser'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId, query } from '@/test-utils/dom'; +import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; +import { GrpcMetadataTable } from './GrpcMetadataTable'; + +const rows = (entries: Record[]) => entries as unknown as GrpcMetadata[]; + +const cellTexts = (table: HTMLElement, key: string): string[] => + table.querySelectorAll(`[data-testid="table-cell-${key}"]`).map((cell) => cell.text.trim()); + +describe('GrpcMetadataTable', () => { + it('renders nothing when there is no metadata', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'grpc-metadata-table')).toBeNull(); + }); + + it('renders a name, value and description for every row', () => { + const root = useRenderToDom( + + ); + + const table = getByTestId(root, 'grpc-metadata-table'); + expect(cellTexts(table, 'name')).toEqual(['authorization', 'x-request-id']); + expect(cellTexts(table, 'value')).toEqual(['Bearer token', 'req-001']); + expect(cellTexts(table, 'description')).toEqual(['Auth token', '']); + }); + + it('reads a description given as an object', () => { + const root = useRenderToDom( + + ); + + const table = getByTestId(root, 'grpc-metadata-table'); + expect(cellTexts(table, 'description')).toEqual(['Client name']); + }); + + it('marks a disabled row', () => { + const root = useRenderToDom( + + ); + + const table = getByTestId(root, 'grpc-metadata-table'); + expect(cellTexts(table, 'name')).toEqual(['x-legacy-flag']); + expect(getByTestId(table, 'disabled-badge').text).toBe('Disabled'); + }); + + it('highlights a variable in a value', () => { + const root = useRenderToDom( + + ); + + const table = getByTestId(root, 'grpc-metadata-table'); + expect(query(table, '.var-text').text).toContain('token'); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.tsx new file mode 100644 index 00000000..6ad61286 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/GrpcMetadataTable.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; +import { getDescription } from '@/utils/request'; +import { Table, type TableColumn } from '@/ui/Table/Table'; +import { TruncatedText } from '@/components/TruncatedText/TruncatedText'; +import { VariableText } from '@/components/VariableText/VariableText'; +import { DisabledBadge } from '@/components/DisabledBadge/DisabledBadge'; +import { StyledWrapper } from './StyledWrapper'; + +const COLUMNS: TableColumn[] = [ + { key: 'name', header: 'Name', width: '22%' }, + { key: 'value', header: 'Value', width: '36%' }, + { key: 'description', header: 'Description', width: '42%' } +]; + +interface GrpcMetadataTableProps { + metadata: GrpcMetadata[]; + testId?: string; +} + +export const GrpcMetadataTable: React.FC = ({ + metadata, + testId = 'grpc-metadata-table' +}) => { + if (metadata.length === 0) return null; + + return ( + + { + const description = getDescription(entry); + return { + id: `${entry.name}-${index}`, + cells: { + name: , + value: ( + + + + + {entry.disabled ? : null} + + ), + description: description ? : null + } + }; + })} + /> + + ); +}; + +export default GrpcMetadataTable; diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/StyledWrapper.ts new file mode 100644 index 00000000..4725f6f0 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMetadataTable/StyledWrapper.ts @@ -0,0 +1,25 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + .table-cell { + font-size: 0.75rem; + line-height: 1.2; + } + + .grpc-metadata-value { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + font-family: var(--font-mono); + color: var(--oc-colors-text-subtext2); + } + .grpc-metadata-value .disabled-badge { + margin-left: auto; + flex-shrink: 0; + } + + .table-cell:last-child { + color: var(--oc-colors-text-subtext0); + } +`; diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx new file mode 100644 index 00000000..700b4eb5 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.spec.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import { GrpcMethodTypeIcon } from './GrpcMethodTypeIcon'; + +describe('GrpcMethodTypeIcon', () => { + it.each([ + ['unary', 'get'], + ['server-streaming', 'put'], + ['client-streaming', 'head'], + ['bidi-streaming', 'post'] + ])('colours %s from the %s method variable', (methodType, token) => { + const root = useRenderToDom(); + const icon = getByTestId(root, 'grpc-method-type-icon'); + expect(icon.attributes.style).toContain(`var(--oc-request-methods-${token})`); + expect(icon.querySelector('svg')).not.toBeNull(); + }); + + it('renders nothing when the method type is absent', () => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'grpc-method-type-icon')).toBeNull(); + }); + + it.each(['toString', 'constructor', 'hasOwnProperty', '__proto__'])( + 'renders nothing for a methodType named %s instead of crashing', + (methodType) => { + const root = useRenderToDom(); + expect(queryByTestId(root, 'grpc-method-type-icon')).toBeNull(); + } + ); +}); diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx new file mode 100644 index 00000000..f84df037 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/GrpcMethodTypeIcon.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import type { GrpcMethodType } from '@opencollection/types/requests/grpc'; +import { + UnaryIcon, + ServerStreamingIcon, + ClientStreamingIcon, + BidiStreamingIcon +} from '@/assets/icons'; +import { StyledWrapper } from './StyledWrapper'; + +// Method types borrow the HTTP method colour tokens rather than defining their own, so both +// themes stay in step without new tokens. Client-streaming maps to the head colour, which is +// where this differs from the Bruno app's own mapping. +const ICON_BY_METHOD_TYPE: Record; color: string }> = { + 'unary': { icon: UnaryIcon, color: 'var(--oc-request-methods-get)' }, + 'server-streaming': { icon: ServerStreamingIcon, color: 'var(--oc-request-methods-put)' }, + 'client-streaming': { icon: ClientStreamingIcon, color: 'var(--oc-request-methods-head)' }, + 'bidi-streaming': { icon: BidiStreamingIcon, color: 'var(--oc-request-methods-post)' } +}; + +interface GrpcMethodTypeIconProps { + methodType?: GrpcMethodType; + size?: number; + className?: string; + testId?: string; +} + +export const GrpcMethodTypeIcon: React.FC = ({ + methodType, + size = 16, + className, + testId = 'grpc-method-type-icon' +}) => { + const entry + = methodType && Object.prototype.hasOwnProperty.call(ICON_BY_METHOD_TYPE, methodType) + ? ICON_BY_METHOD_TYPE[methodType] + : undefined; + if (!entry) return null; + + const Icon = entry.icon; + return ( + + + + ); +}; + +export default GrpcMethodTypeIcon; diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/StyledWrapper.ts new file mode 100644 index 00000000..a51e2796 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcMethodTypeIcon/StyledWrapper.ts @@ -0,0 +1,6 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.span` + display: inline-flex; + flex-shrink: 0; +`; diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx new file mode 100644 index 00000000..1ae73b38 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.spec.tsx @@ -0,0 +1,384 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId, query } from '@/test-utils/dom'; +import type { GrpcRequest as GrpcRequestItem } from '@opencollection/types/requests/grpc'; +import { GrpcRequest } from './GrpcRequest'; + +const grpcItem = (data: Record): GrpcRequestItem => data as unknown as GrpcRequestItem; + +describe('GrpcRequest', () => { + it('renders the request name, the GRPC badge and the url', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'grpc-request-title').text).toBe('Order Service'); + expect(getByTestId(root, 'request-method').text).toBe('gRPC'); + expect(getByTestId(root, 'request-url').text).toContain('grpc://localhost:50051'); + }); + + it('renders a request that has no grpc block at all', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'grpc-request-title').text).toBe('Bare Method'); + expect(getByTestId(root, 'request-url').text).toContain('{{grpcUrl}}'); + }); + + it('falls back to a placeholder name and never offers a Try button', () => { + const root = useRenderToDom(); + + expect(getByTestId(root, 'grpc-request-title').text).toBe('Untitled Request'); + expect(queryByTestId(root, 'request-try-button')).toBeNull(); + }); + + it('renders the docs markdown as html', () => { + const root = useRenderToDom( + + ); + + const description = getByTestId(root, 'grpc-request-description'); + const markdown = query(description, '.markdown-documentation'); + expect(query(markdown, 'h1').text).toBe('Order Service'); + expect(query(markdown, 'p').text).toBe('Fetches a single order.'); + }); + + it('omits the description block when there are no docs', () => { + const root = useRenderToDom( + + ); + expect(queryByTestId(root, 'grpc-request-description')).toBeNull(); + }); + + it('renders a request with a method', () => { + const root = useRenderToDom( + + ); + expect(getByTestId(root, 'grpc-request-method').text).toContain('GetOrder'); + }); + + it('renders the proto file name and the method with its type label', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'grpc-request-proto-file').text).toContain('book.proto'); + expect(getByTestId(root, 'grpc-request-method').text).toContain('com.bookstore.BookService/GetBook'); + expect(getByTestId(root, 'grpc-request-method-type').text).toBe('Unary'); + }); + + it('hides the proto file path when the request uses reflection', () => { + const root = useRenderToDom( + + ); + + expect(queryByTestId(root, 'grpc-request-section-proto-file')).toBeNull(); + expect(getByTestId(root, 'grpc-request-method-type').text).toBe('Bidirectional Streaming'); + }); + + it('hides the method section when no method is selected', () => { + const root = useRenderToDom( + + ); + + expect(queryByTestId(root, 'grpc-request-section-method')).toBeNull(); + expect(getByTestId(root, 'grpc-request-title').text).toBe('Bare Method'); + }); + + it('renders metadata rows with their descriptions and counts only enabled ones', () => { + const root = useRenderToDom( + + ); + + const section = getByTestId(root, 'grpc-request-section-metadata'); + const names = section.querySelectorAll('[data-testid="table-cell-name"]').map((cell) => cell.text.trim()); + expect(names).toEqual(['authorization', 'x-request-id', 'x-legacy-flag']); + expect(section.text).toContain('Auth token'); + expect(section.text).toContain('2 fields'); + }); + + it('reads a metadata description given as an object', () => { + const root = useRenderToDom( + + ); + + const section = getByTestId(root, 'grpc-request-section-metadata'); + expect(section.text).toContain('Client name'); + expect(section.text).toContain('1 field'); + }); + + it('hides the metadata section when there is none', () => { + const root = useRenderToDom( + + ); + expect(queryByTestId(root, 'grpc-request-section-metadata')).toBeNull(); + }); + + it('shows concrete auth with no inherited badge', () => { + const root = useRenderToDom( + + ); + + const section = getByTestId(root, 'grpc-request-section-auth'); + expect(section.text).toContain('Basic Auth'); + expect(section.text).toContain('reader'); + expect(queryByTestId(root, 'grpc-request-auth-inherited')).toBeNull(); + }); + + it('resolves inherited auth up to the collection and says where it came from', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'grpc-request-auth-inherited').text).toContain('Inherited from collection'); + expect(getByTestId(root, 'grpc-request-section-auth').text).toContain('Bearer Token'); + }); + + it('masks a secret rather than printing it', () => { + const root = useRenderToDom( + + ); + expect(root.text).not.toContain('s3cret'); + }); + + it('hides the auth section when the request has no auth', () => { + const root = useRenderToDom( + + ); + expect(queryByTestId(root, 'grpc-request-section-auth')).toBeNull(); + }); + + it('shows a single empty state when the request has no configuration', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'grpc-request-config-empty').text).toContain('No request configuration'); + expect(getByTestId(root, 'grpc-request-title').text).toBe('Bare Method'); + }); + + it('builds a grpcurl snippet from the request', () => { + const root = useRenderToDom( + + ); + + expect(getByTestId(root, 'grpc-request-code-snippet-tab-grpcurl').text).toBe('grpcURL'); + const code = getByTestId(root, 'grpc-request-code-snippet-code'); + expect(code.text).toContain('grpcurl'); + expect(code.text).toContain('localhost:50051'); + expect(code.text).toContain('orders.OrderService/GetOrder'); + }); + + it('omits the code snippet when the request has no method', () => { + const root = useRenderToDom( + + ); + expect(queryByTestId(root, 'grpc-request-section-code-snippet')).toBeNull(); + }); + + it('shows sections instead of the empty state when there is any configuration', () => { + const root = useRenderToDom( + + ); + + expect(queryByTestId(root, 'grpc-request-config-empty')).toBeNull(); + expect(queryByTestId(root, 'grpc-request-section-method')).not.toBeNull(); + }); + + it('offers a JavaScript snippet only when a proto file is attached', () => { + const withProto = useRenderToDom( + + ); + expect(queryByTestId(withProto, 'grpc-request-code-snippet-tab-javascript')).not.toBeNull(); + + const reflectionOnly = useRenderToDom( + + ); + expect(queryByTestId(reflectionOnly, 'grpc-request-code-snippet-tab-grpcurl')).not.toBeNull(); + expect(queryByTestId(reflectionOnly, 'grpc-request-code-snippet-tab-javascript')).toBeNull(); + }); +}); + +describe('GrpcRequest — execution context', () => { + const useWithRuntime = (runtime: Record) => + useRenderToDom( + + ); + + it('renders an empty state when the request carries no runtime', () => { + const root = useRenderToDom( + + ); + + const section = getByTestId(root, 'grpc-request-section-execution-context'); + expect(getByTestId(section, 'grpc-request-execution-context-empty').text).toContain('No execution context'); + }); + + it('renders pre-request variables from the runtime block', () => { + const root = useWithRuntime({ variables: [{ name: 'orderId', value: '12345' }] }); + expect(queryByTestId(root, 'grpc-request-execution-context-empty')).toBeNull(); + expect(getByTestId(root, 'grpc-request-section-execution-context').text).toContain('orderId'); + }); + + it('renders post-response captures stored as actions', () => { + const root = useWithRuntime({ + actions: [ + { + type: 'set-variable', + trigger: 'after-response', + variable: { name: 'lastOrderStatus', scope: 'runtime' }, + selector: { expression: 'res.body.status' } + } + ] + }); + expect(queryByTestId(root, 'grpc-request-execution-context-empty')).toBeNull(); + expect(getByTestId(root, 'grpc-request-section-execution-context').text).toContain('lastOrderStatus'); + }); + + it('renders assertions from the runtime block', () => { + const root = useWithRuntime({ assertions: [{ expression: 'res.body.orderId', operator: 'eq', value: '12345' }] }); + expect(queryByTestId(root, 'grpc-request-execution-context-empty')).toBeNull(); + expect(getByTestId(root, 'grpc-request-section-execution-context').text).toContain('res.body.orderId'); + }); + + it('renders scripts from the runtime block', () => { + const root = useWithRuntime({ scripts: [{ type: 'before-request', code: 'bru.setVar(\'requestedAt\', Date.now());' }] }); + expect(queryByTestId(root, 'grpc-request-execution-context-empty')).toBeNull(); + }); + + it('labels the script-chain request marker as GRPC', () => { + const root = useWithRuntime({ scripts: [{ type: 'before-request', code: 'bru.setVar(\'requestedAt\', Date.now());' }] }); + expect(getByTestId(root, 'script-chain-request-label').text).toBe('GRPC'); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx new file mode 100644 index 00000000..b1c242b6 --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/GrpcRequest.tsx @@ -0,0 +1,323 @@ +import React, { useMemo } from 'react'; +import type { OpenCollection } from '@opencollection/types'; +import type { Item } from '@opencollection/types/collection/item'; +import type { GrpcRequest as GrpcRequestItem } from '@opencollection/types/requests/grpc'; +import type { Auth } from '@opencollection/types/common/auth'; +import { + getItemName, + getRequestUrl, + getItemDocs, + getRequestAuth, + getGrpcMethod, + getGrpcMethodType, + getGrpcMetadata, + getGrpcMessages, + getGrpcProtoFileName, + getGrpcProtoFilePath, + countEnabled +} from '@/utils/schemaHelpers'; +import { + resolveInheritedAuth, + getPreRequestVars, + getPostResponseVars, + buildScriptChain, + getScriptFlow +} from '@/utils/request'; +import { collectAssertions } from '@/utils/assertions'; +import { collectTests, collectRawTestScripts } from '@/utils/fileUtils'; +import { ExecutionContext } from '@/components/ExecutionContext/ExecutionContext'; +import { generateGrpcurlCommand, generateGrpcJavaScriptCode, grpcMethodPath } from '@/utils/grpcSnippets'; +import { SnippetTabs, type Snippet } from '@/components/SnippetTabs/SnippetTabs'; +import { useMarkdownRenderer, useResolvedVariables } from '@/hooks'; +import { singleReferenceName } from '@/utils/variableResolution'; +import { buildBreadcrumbSegments } from '@/utils/common'; +import { AUTH_MODE_LABELS, GRPC_METHOD_TYPE_LABELS } from '@/constants'; +import { Section } from '@/components/Section/Section'; +import { ContentTypeBadge } from '@/components/ContentTypeBadge/ContentTypeBadge'; +import { InheritedAuthBadge } from '@/components/InheritedAuthBadge/InheritedAuthBadge'; +import { AuthDetails } from '@/components/AuthDetails/AuthDetails'; +import { GrpcMethodTypeIcon } from './GrpcMethodTypeIcon/GrpcMethodTypeIcon'; +import { GrpcMessages } from './GrpcMessages/GrpcMessages'; +import { GrpcMetadataTable } from './GrpcMetadataTable/GrpcMetadataTable'; +import { PageWrapper } from '@/components/PageWrapper/PageWrapper'; +import { Heading } from '@/components/Heading/Heading'; +import { ViewMore } from '@/components/ViewMore/ViewMore'; +import { Breadcrumb, type BreadcrumbSegment } from '@/ui/Breadcrumb/Breadcrumb'; +import { EmptyState } from '@/ui/EmptyState/EmptyState'; +import { RequestUrlBar } from '@/components/Request/RequestUrlBar/RequestUrlBar'; +import { StyledWrapper } from './StyledWrapper'; +import { FileIcon, RefreshIcon } from '@/assets/icons'; + +const NO_ANCESTRY: Item[] = []; + +const NAV_GROUP = { configuration: 'Configuration' } as const; +const NAV_LEVEL = { section: 1, configItem: 2 } as const; + +interface GrpcRequestProps { + item: GrpcRequestItem; + collection?: OpenCollection | null; + ancestry?: Item[]; + onBreadcrumbClick?: (uuid: string) => void; + testId?: string; +} + +export const GrpcRequest: React.FC = ({ + item, + ancestry = NO_ANCESTRY, + collection, + onBreadcrumbClick, + testId = 'grpc-request-page' +}) => { + const name = getItemName(item) || 'Untitled Request'; + const url = getRequestUrl(item); + + const method = getGrpcMethod(item); + const methodType = getGrpcMethodType(item); + const protoFileName = getGrpcProtoFileName(item); + const protoFilePath = getGrpcProtoFilePath(item); + const methodTypeLabel + = methodType && Object.prototype.hasOwnProperty.call(GRPC_METHOD_TYPE_LABELS, methodType) + ? GRPC_METHOD_TYPE_LABELS[methodType] + : undefined; + const messages = useMemo(() => getGrpcMessages(item), [item]); + const metadata = useMemo(() => getGrpcMetadata(item), [item]); + const enabledMetadataCount = countEnabled(metadata); + + const ownAuth = getRequestAuth(item) as Auth | undefined; + const resolvedAuth = useMemo(() => resolveInheritedAuth(collection, ancestry, item), [collection, ancestry, item]); + const effectiveAuth = ownAuth === 'inherit' ? resolvedAuth.auth : ownAuth; + const showAuth = ownAuth !== undefined; + const authBadge + = ownAuth === 'inherit' ? ( + resolvedAuth.source ? ( + + ) : ( + + ) + ) : undefined; + + const hasLeftColumn + = Boolean(protoFileName) || Boolean(method) || messages.length > 0 || metadata.length > 0 || showAuth; + + const { lookup } = useResolvedVariables(); + + const resolvedUrl = useMemo(() => { + const variableName = singleReferenceName(url); + if (!variableName) return undefined; + const entry = lookup(variableName); + return entry.secret ? undefined : entry.value || undefined; + }, [url, lookup]); + + const snippets = useMemo(() => { + if (!method) return []; + const input = { url, resolvedUrl, method, methodType, protoFilePath, metadata, messages, auth: effectiveAuth }; + const built: Snippet[] = [ + { id: 'grpcurl', label: 'grpcURL', language: 'bash', code: generateGrpcurlCommand(input) } + ]; + + const javaScript = generateGrpcJavaScriptCode(input); + if (javaScript) { + built.push({ id: 'javascript', label: 'JavaScript', language: 'javascript', code: javaScript }); + } + + return built; + }, [url, resolvedUrl, method, methodType, protoFilePath, metadata, messages, effectiveAuth]); + + const md = useMarkdownRenderer(); + + const descHtml = useMemo(() => { + const docs = getItemDocs(item); + return docs ? md.render(docs) : ''; + }, [item, md]); + + const segments = useMemo( + () => buildBreadcrumbSegments(collection, ancestry), + [collection, ancestry] + ); + + const preVars = useMemo(() => getPreRequestVars(item), [item]); + const postVars = useMemo(() => getPostResponseVars(item), [item]); + const scriptChain = useMemo(() => buildScriptChain(collection, ancestry, item), [collection, ancestry, item]); + const scriptFlow = useMemo(() => getScriptFlow(collection), [collection]); + const assertions = useMemo(() => collectAssertions(item), [item]); + const tests = useMemo( + () => collectTests(collection, ancestry, item, scriptFlow), + [collection, ancestry, item, scriptFlow] + ); + const testScripts = useMemo( + () => collectRawTestScripts(collection, ancestry, item, scriptFlow), + [collection, ancestry, item, scriptFlow] + ); + + const hasExecutionContext + = scriptChain.length > 0 || preVars.length > 0 || postVars.length > 0 || assertions.length > 0 || tests.length > 0; + + return ( + + + + + {name} + + + {descHtml && ( + +
+ + )} + + {hasLeftColumn ? ( +
+
+ {protoFileName && ( +
+
+ + + + {protoFileName} +
+
+ )} + + {method && ( +
+
+ + {grpcMethodPath(method)} + {methodTypeLabel && ( + + {methodTypeLabel} + + )} +
+
+ )} + + {messages.length > 0 && ( +
+ } + > + +
+ )} + + {metadata.length > 0 && ( +
+ ) : null + } + > + +
+ )} + + {showAuth && ( +
+ +
+ )} +
+ + {snippets.length > 0 && ( +
+
+ +
+
+ )} +
+ ) : ( + } + heading="No request configuration" + subheading="This request has no method, messages, metadata, or authentication configured." + /> + )} + +
+ {hasExecutionContext ? ( + + ) : ( + } + heading="No execution context" + subheading="This request has no scripts, variables, asserts, or tests configured." + /> + )} +
+ + + ); +}; + +export default GrpcRequest; diff --git a/packages/bruno-api-docs/src/pages/GrpcRequest/StyledWrapper.ts b/packages/bruno-api-docs/src/pages/GrpcRequest/StyledWrapper.ts new file mode 100644 index 00000000..c2372aed --- /dev/null +++ b/packages/bruno-api-docs/src/pages/GrpcRequest/StyledWrapper.ts @@ -0,0 +1,86 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + max-width: 100rem; + margin: 0 auto; + color: var(--text-primary); + padding-top: 0.1rem; + padding-bottom: 0.1rem; + + .grpc-request-empty { + margin-top: 1.5rem; + } + + .grpc-request-fullwidth { + margin-top: 2rem; + padding-top: 2rem; + } + + .grpc-request-columns { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + gap: 2.75rem; + align-items: start; + margin-top: 1.25rem; + } + + .grpc-request-col-left { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .grpc-request-col-right { + min-width: 0; + position: sticky; + top: 1.25rem; + align-self: start; + } + + .grpc-field { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + border: 1px solid var(--border-color); + border-radius: var(--oc-radius); + background-color: var(--oc-background-mantle); + } + + .grpc-field-icon { + flex-shrink: 0; + display: inline-flex; + color: var(--text-tertiary); + } + + .grpc-field-text { + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.125rem; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .grpc-field-meta { + flex-shrink: 0; + font-family: var(--font-sans); + font-size: 0.75rem; + line-height: 1.125rem; + color: var(--text-tertiary); + } + + @container docs (max-width: 1024px) { + .grpc-request-columns { + grid-template-columns: 1fr; + gap: 1.75rem; + } + .grpc-request-col-right { + position: static; + } + } +`; diff --git a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx index 0fe87cef..d6f9a9b0 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.spec.tsx @@ -4,9 +4,10 @@ import type { OpenCollection } from '@opencollection/types'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { Item } from '@opencollection/types/collection/item'; import { MemoryRouter } from 'react-router-dom'; +import type { RequestItem } from '@/utils/schemaHelpers'; import { Request } from './Request'; -import { useRenderToDom } from '../../hooks/useRenderToDom'; -import { getByTestId, queryByTestId } from '../../test-utils/dom'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; const collection: OpenCollection = { info: { name: 'Auth API', version: '1.0.0' }, @@ -271,3 +272,29 @@ describe('Request page', () => { expect(description.text).not.toContain('©'); // © }); }); + +describe('Request — unrecognised request types', () => { + const unknownItem = (data: Record): RequestItem => data as unknown as RequestItem; + + it('renders a type the viewer has no page for as a request rather than a blank page', () => { + const root = useRenderToDom( + + + + ); + + expect(getByTestId(root, 'request-page')).toBeTruthy(); + expect(getByTestId(root, 'request-title').text).toContain('Quantum Ping'); + }); + + it('does the same for an item that names no type at all', () => { + const root = useRenderToDom( + + + + ); + + expect(getByTestId(root, 'request-page')).toBeTruthy(); + expect(getByTestId(root, 'request-title').text).toContain('Mystery'); + }); +}); diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index 06e36a9b..cb2daf13 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -3,9 +3,6 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item } from '@opencollection/types/collection/item'; import type { HttpRequest, HttpRequestParam, HttpRequestHeader } from '@opencollection/types/requests/http'; import type { Auth } from '@opencollection/types/common/auth'; -import type { GraphQLRequest } from '@opencollection/types/requests/graphql'; -import type { GrpcRequest } from '@opencollection/types/requests/grpc'; -import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; import { useMarkdownRenderer } from '../../hooks'; import { AUTH_MODE_LABELS } from '../../constants'; import { @@ -19,7 +16,9 @@ import { getItemDocs, getItemDescription, getRequestExamples, - isUnsupportedRequest + isUnsupportedRequestInDocs, + isGrpcRequest, + type RequestItem } from '../../utils/schemaHelpers'; import { resolveInheritedAuth, @@ -55,10 +54,11 @@ import { CodeSnippetTabs } from '../../components/CodeSnippetTabs/CodeSnippetTab import { Examples } from '../../components/Examples/Examples'; import { ExecutionContext } from '../../components/ExecutionContext/ExecutionContext'; import { UnsupportedRequest } from '../../components/UnsupportedRequest/UnsupportedRequest'; +import { GrpcRequest } from '../GrpcRequest/GrpcRequest'; import { StyledWrapper } from './StyledWrapper'; interface RequestProps { - item: HttpRequest | WebSocketRequest | GraphQLRequest | GrpcRequest; + item: RequestItem; ancestry?: Item[]; collection?: OpenCollection | null; onTryClick?: () => void; @@ -280,7 +280,6 @@ const RequestContent: React.FC = ({ tests={tests} testScripts={testScripts} flow={scriptFlow} - method={method} url={url} onNavigate={onBreadcrumbClick} /> @@ -306,7 +305,18 @@ export const Request: React.FC = ({ onBreadcrumbClick, highlightedExampleIndex }) => { - if (isUnsupportedRequest(item)) { + if (isGrpcRequest(item)) { + return ( + + ); + } + + if (isUnsupportedRequestInDocs(item)) { return ( = ({ return ( { expect(root.querySelector('table')).toBeNull(); }); }); + +describe('Table — hidden header', () => { + const rows = [{ id: 'r1', cells: { name: 'authorization', value: 'Bearer t' } }]; + + it('keeps the header cells in the markup so their scope survives', () => { + const root = useRenderToDom(
); + + const headers = root.querySelectorAll('th[scope="col"]'); + expect(headers.length).toBe(2); + expect(headers[0].text).toContain('Name'); + }); + + it('marks the header row hidden rather than dropping it', () => { + const hidden = useRenderToDom(
); + expect(hidden.querySelector('thead')?.attributes.class).toContain('table-head--hidden'); + + const shown = useRenderToDom(
); + expect(shown.querySelector('thead')?.attributes.class).not.toContain('table-head--hidden'); + }); +}); diff --git a/packages/bruno-api-docs/src/ui/Table/Table.tsx b/packages/bruno-api-docs/src/ui/Table/Table.tsx index d8043d7e..0cf99d60 100644 --- a/packages/bruno-api-docs/src/ui/Table/Table.tsx +++ b/packages/bruno-api-docs/src/ui/Table/Table.tsx @@ -32,6 +32,7 @@ interface TableProps { caption?: string; emptyMessage?: string; minWidth?: string; + hideHeader?: boolean; className?: string; testId?: string; } @@ -89,6 +90,7 @@ export const Table: React.FC = ({ caption, emptyMessage, minWidth, + hideHeader = false, className, testId }) => { @@ -112,7 +114,7 @@ export const Table: React.FC = ({ ))} - + {columns.map((column) => (
+export const collectAssertions = (item: HttpRequest | GrpcRequest): AssertionRow[] => getRequestAssertions(item).map((assertion: Assertion) => { const unary = isUnaryOperator(assertion.operator); return { diff --git a/packages/bruno-api-docs/src/utils/codeSnippets.ts b/packages/bruno-api-docs/src/utils/codeSnippets.ts index dfb8950b..f98b097d 100644 --- a/packages/bruno-api-docs/src/utils/codeSnippets.ts +++ b/packages/bruno-api-docs/src/utils/codeSnippets.ts @@ -89,7 +89,7 @@ const normalizeBody = (raw: SnippetInput['body']): NormalizedBody => { * (api-key in the query, awsv4/digest, basic with variables, …) returns a * `comment` instead, which the caller renders as a leading comment in the snippet. */ -const authToHeaders = (auth: Auth | undefined): { headers: SnippetHeader[]; comment?: string } => { +export const authToHeaders = (auth: Auth | undefined): { headers: SnippetHeader[]; comment?: string } => { if (!auth || auth === 'inherit') return { headers: [] }; switch (auth.type) { case AUTH_TYPES.BASIC: { diff --git a/packages/bruno-api-docs/src/utils/fileUtils.ts b/packages/bruno-api-docs/src/utils/fileUtils.ts index 043a55a3..e8d58e80 100644 --- a/packages/bruno-api-docs/src/utils/fileUtils.ts +++ b/packages/bruno-api-docs/src/utils/fileUtils.ts @@ -1,6 +1,7 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item, Folder } from '@opencollection/types/collection/item'; import type { HttpRequest } from '@opencollection/types/requests/http'; +import type { GrpcRequest } from '@opencollection/types/requests/grpc'; import type { Scripts } from '@opencollection/types/common/scripts'; import { getItemName, getRequestScripts, scriptsArrayToObject, isFolder } from './schemaHelpers'; import { isYamlFile, parseYaml } from './yamlUtils'; @@ -306,7 +307,7 @@ interface TestSource { const forEachTestSource = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest, + item: HttpRequest | GrpcRequest, flow: ScriptFlow, visit: (level: TestRow['level'], code: string | undefined, sourceName?: string) => void ): void => { @@ -327,7 +328,7 @@ const forEachTestSource = ( export const collectTests = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest, + item: HttpRequest | GrpcRequest, flow: ScriptFlow = 'sandwich' ): TestRow[] => { const rows: TestRow[] = []; @@ -356,7 +357,7 @@ export interface RawTestScript { export const collectRawTestScripts = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest, + item: HttpRequest | GrpcRequest, flow: ScriptFlow = 'sandwich' ): RawTestScript[] => { const scripts: RawTestScript[] = []; diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts new file mode 100644 index 00000000..d90c7534 --- /dev/null +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.spec.ts @@ -0,0 +1,369 @@ +import { describe, it, expect } from 'vitest'; +import type { GrpcMetadata } from '@opencollection/types/requests/grpc'; +import { generateGrpcJavaScriptCode, generateGrpcurlCommand, type GrpcSnippetInput } from './grpcSnippets'; + +const input = (overrides: Partial = {}): GrpcSnippetInput => ({ + url: 'grpc://localhost:50051', + method: '/hello.HelloService/SayHello', + metadata: [] as GrpcMetadata[], + messages: [{ title: 'Message 1', message: '{"greeting":"hi"}' }], + ...overrides +}); + +describe('generateGrpcurlCommand', () => { + it('builds a unary command with the message inline', () => { + const command = generateGrpcurlCommand(input({ methodType: 'unary' })); + expect(command).toContain('grpcurl'); + expect(command).toContain('-plaintext'); + expect(command).toContain(`-d '{"greeting":"hi"}'`); + expect(command).toContain('localhost:50051'); + expect(command).toContain('hello.HelloService/SayHello'); + }); + + it('strips the scheme and the leading slash of the method', () => { + const command = generateGrpcurlCommand(input()); + expect(command).not.toContain('grpc://'); + expect(command).not.toContain('/hello.HelloService'); + }); + + it('omits plaintext for a TLS target', () => { + const command = generateGrpcurlCommand(input({ url: 'grpcs://api.example.com:443' })); + expect(command).not.toContain('-plaintext'); + expect(command).toContain('api.example.com:443'); + }); + + it('passes enabled metadata as headers and skips disabled ones', () => { + const command = generateGrpcurlCommand( + input({ + metadata: [ + { name: 'authorization', value: 'Bearer t' }, + { name: 'x-legacy', value: 'off', disabled: true } + ] as GrpcMetadata[] + }) + ); + expect(command).toContain(`-H 'authorization: Bearer t'`); + expect(command).not.toContain('x-legacy'); + }); + + it('adds the import path and proto file when one is attached', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: 'protos/telemetry/v1/telemetry.proto' })); + expect(command).toContain(`-import-path 'protos/telemetry/v1'`); + expect(command).toContain(`-proto 'telemetry.proto'`); + }); + + it('omits proto flags when the request uses reflection', () => { + const command = generateGrpcurlCommand(input()); + expect(command).not.toContain('-proto'); + expect(command).not.toContain('-import-path'); + }); + + it('pipes every message through stdin for client streaming', () => { + const command = generateGrpcurlCommand( + input({ + methodType: 'client-streaming', + messages: [ + { title: 'Message 1', message: '{"n":1}' }, + { title: 'Message 2', message: '{"n":2}' } + ] + }) + ); + expect(command).toContain('-d @'); + expect(command).toContain(`<< 'EOF'`); + expect(command).toContain('{"n":1}'); + expect(command).toContain('{"n":2}'); + expect(command.trimEnd().endsWith('EOF')).toBe(true); + }); + + it('pipes messages through stdin for bidi streaming too', () => { + const command = generateGrpcurlCommand( + input({ methodType: 'bidi-streaming', messages: [{ title: 'Message 1', message: '{"n":1}' }] }) + ); + expect(command).toContain('-d @'); + expect(command).toContain(`<< 'EOF'`); + }); + + it('leaves out the data flag when the request has no messages', () => { + const command = generateGrpcurlCommand(input({ messages: [] })); + expect(command).not.toContain('-d'); + expect(command).toContain('hello.HelloService/SayHello'); + }); + + it('keeps a quote in a metadata value inside the quoted header', () => { + const command = generateGrpcurlCommand( + input({ metadata: [{ name: 'x-note', value: 'it\'s here' }] as GrpcMetadata[] }) + ); + expect(command).toContain(`-H 'x-note: it'\\''s here'`); + }); + + it('keeps a quote in a message inside the quoted data flag', () => { + const command = generateGrpcurlCommand(input({ messages: [{ title: 'Message 1', message: `{"note":"it's"}` }] })); + expect(command).toContain(`-d '{"note":"it'\\''s"}'`); + }); + + it('keeps a proto path with spaces as one shell word', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: 'my protos/book service.proto' })); + expect(command).toContain(`-import-path 'my protos'`); + expect(command).toContain(`-proto 'book service.proto'`); + }); + + it('quotes the target and the method so neither can split or execute', () => { + const command = generateGrpcurlCommand( + input({ url: 'grpc://host$(whoami):50051', method: '/pkg.Svc/Do$(whoami)' }) + ); + expect(command).toContain(`'host$(whoami):50051'`); + expect(command).toContain(`'pkg.Svc/Do$(whoami)'`); + }); + + it('drops plaintext when the scheme is TLS but hidden inside a variable', () => { + const command = generateGrpcurlCommand(input({ url: '{{host}}', resolvedUrl: 'grpcs://grpcb.in:9001' })); + expect(command).not.toContain('-plaintext'); + expect(command).toContain(`'{{host}}'`); + }); + + it('keeps plaintext when the variable resolves to an unencrypted scheme', () => { + const command = generateGrpcurlCommand(input({ url: '{{host}}', resolvedUrl: 'grpc://grpcb.in:9000' })); + expect(command).toContain('-plaintext'); + expect(command).toContain(`'{{host}}'`); + }); + + it('assumes plaintext when the variable resolves to a bare address', () => { + const command = generateGrpcurlCommand(input({ url: '{{host}}', resolvedUrl: 'grpcb.in:9000' })); + expect(command).toContain('-plaintext'); + }); + + it('assumes plaintext when the variable cannot be resolved', () => { + expect(generateGrpcurlCommand(input({ url: '{{host}}' }))).toContain('-plaintext'); + }); + + it('lets a scheme written into the url win over the resolved value', () => { + const command = generateGrpcurlCommand(input({ url: 'grpcs://api.example.com:443', resolvedUrl: 'grpc://ignored' })); + expect(command).not.toContain('-plaintext'); + expect(command).toContain(`'api.example.com:443'`); + }); + + it('neutralises a proto path that carries a shell command', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: 'protos/book$(rm -rf ~).proto' })); + expect(command).toContain(`-proto 'book$(rm -rf ~).proto'`); + }); +}); + +describe('generateGrpcJavaScriptCode', () => { + const withProto = (overrides: Partial = {}) => + input({ protoFilePath: 'protos/hello.proto', ...overrides }); + + it('loads the proto file and builds a client for the service', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'unary' })); + expect(code).toContain(`protoLoader.loadSync('protos/hello.proto')`); + expect(code).toContain(`new proto.hello.HelloService('localhost:50051', grpc.credentials.createInsecure())`); + expect(code).toContain('client.SayHello('); + }); + + it('uses TLS credentials for a grpcs target', () => { + const code = generateGrpcJavaScriptCode(withProto({ url: 'grpcs://api.example.com:443' })); + expect(code).toContain('grpc.credentials.createSsl()'); + }); + + it('uses TLS credentials when the scheme is hidden inside a variable', () => { + const code = generateGrpcJavaScriptCode(withProto({ url: '{{host}}', resolvedUrl: 'grpcs://grpcb.in:9001' })); + expect(code).toContain('grpc.credentials.createSsl()'); + expect(code).toContain(`'{{host}}'`); + }); + + it('adds enabled metadata and skips disabled rows', () => { + const code = generateGrpcJavaScriptCode( + withProto({ + metadata: [ + { name: 'authorization', value: 'Bearer t' }, + { name: 'x-legacy', value: 'off', disabled: true } + ] as GrpcMetadata[] + }) + ); + expect(code).toContain(`metadata.set('authorization', 'Bearer t')`); + expect(code).not.toContain('x-legacy'); + }); + + it('listens for data on a server-streaming call', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'server-streaming' })); + expect(code).toContain('const call = client.SayHello('); + expect(code).toContain(`call.on('data'`); + expect(code).not.toContain('call.write'); + }); + + it('writes every message for a client-streaming call', () => { + const code = generateGrpcJavaScriptCode( + withProto({ + methodType: 'client-streaming', + messages: [ + { title: 'Message 1', message: '{"n":1}' }, + { title: 'Message 2', message: '{"n":2}' } + ] + }) + ); + expect(code).toContain('const messages = ['); + expect(code).toContain('{"n":1}'); + expect(code).toContain('{"n":2}'); + expect(code).toContain('call.write(message)'); + expect(code).toContain('call.end()'); + }); + + it('both reads and writes on a bidi call', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'bidi-streaming' })); + expect(code).toContain(`call.on('data'`); + expect(code).toContain('call.write(message)'); + }); + + it('declares an empty message when the request carries none', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'unary', messages: [] })); + expect(code).toContain('const message = {};'); + expect(code).toContain('client.SayHello(message, '); + }); + + it('declares an empty message for a server-streaming request that carries none', () => { + const code = generateGrpcJavaScriptCode(withProto({ methodType: 'server-streaming', messages: [] })); + expect(code).toContain('const message = {};'); + expect(code).toContain('const call = client.SayHello(message);'); + }); + + it('generates nothing when the method names no service', () => { + expect(generateGrpcJavaScriptCode(withProto({ method: 'SayHello' }))).toBe(''); + }); + + it('generates nothing when the request has no proto file', () => { + expect(generateGrpcJavaScriptCode(input())).toBe(''); + }); + + it('escapes a quote in a metadata value', () => { + const code = generateGrpcJavaScriptCode( + withProto({ metadata: [{ name: 'x-note', value: 'it\'s here' }] as GrpcMetadata[] }) + ); + expect(code).toContain(`metadata.set('x-note', 'it\\'s here');`); + }); +}); + +describe('grpcurl and JavaScript hardening', () => { + it('picks a heredoc delimiter no message line can close early', () => { + const command = generateGrpcurlCommand( + input({ + methodType: 'client-streaming', + messages: [ + { title: 'a', message: '{"a":1}\nEOF\nrm -rf ~' }, + { title: 'b', message: '{"b":2}' } + ] + }) + ); + expect(command).toContain(`<< 'EOF2'`); + expect(command.trimEnd().endsWith('EOF2')).toBe(true); + expect(command).toContain('rm -rf ~'); + }); + + it('keeps the plain delimiter when no message collides with it', () => { + const command = generateGrpcurlCommand( + input({ methodType: 'client-streaming', messages: [{ title: 'a', message: '{"a":1}' }] }) + ); + expect(command).toContain(`<< 'EOF'`); + }); + + it('keeps the root of an absolute proto path', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: '/protos/book.proto' })); + expect(command).toContain(`-import-path '/protos'`); + expect(command).toContain(`-proto 'book.proto'`); + }); + + it('keeps a bare root proto path', () => { + const command = generateGrpcurlCommand(input({ protoFilePath: '/book.proto' })); + expect(command).toContain(`-import-path '/'`); + }); + + it('generates no JavaScript when the method is not a plain identifier path', () => { + const hostile = generateGrpcJavaScriptCode( + input({ protoFilePath: 'a.proto', method: '/pkg.Svc/Do(); process.exit(1); //' }) + ); + expect(hostile).toBe(''); + }); + + it('generates no JavaScript when the service segment is not an identifier path', () => { + expect(generateGrpcJavaScriptCode(input({ protoFilePath: 'a.proto', method: '/pkg-svc!/Do' }))).toBe(''); + }); + + it('still generates JavaScript for an ordinary dotted service path', () => { + const code = generateGrpcJavaScriptCode(input({ protoFilePath: 'a.proto', method: '/com.book.BookService/GetBook' })); + expect(code).toContain('new proto.com.book.BookService('); + expect(code).toContain('client.GetBook('); + }); +}); + +describe('message bodies in the JavaScript snippet', () => { + const withProto = (overrides: Partial = {}) => + input({ protoFilePath: 'a.proto', method: '/pkg.Svc/Do', ...overrides }); + + it('keeps a valid JSON body exactly as the author wrote it', () => { + const code = generateGrpcJavaScriptCode(withProto({ messages: [{ title: 'a', message: '{\n "n": 1\n}' }] })); + expect(code).toContain('const message = {\n "n": 1\n};'); + }); + + it('keeps a templated body so its variables survive', () => { + const code = generateGrpcJavaScriptCode(withProto({ messages: [{ title: 'a', message: '{"id":"{{orderId}}"}' }] })); + expect(code).toContain('const message = {"id":"{{orderId}}"};'); + }); + + it('quotes a body that is not JSON so it cannot become executable code', () => { + const code = generateGrpcJavaScriptCode(withProto({ messages: [{ title: 'a', message: '};process.exit(1);//' }] })); + expect(code).toContain(`const message = '};process.exit(1);//';`); + expect(code).not.toContain('const message = };'); + }); + + it('falls back to an empty object for a blank body', () => { + const code = generateGrpcJavaScriptCode(withProto({ messages: [{ title: 'a', message: ' ' }] })); + expect(code).toContain('const message = {};'); + }); +}); + +describe('auth in the generated snippets', () => { + const bearer = { type: 'bearer', token: 'abc123' } as never; + + it('sends bearer auth as grpcurl metadata', () => { + const command = generateGrpcurlCommand(input({ auth: bearer })); + expect(command).toContain(`-H 'Authorization: Bearer abc123'`); + }); + + it('sends bearer auth as JavaScript metadata', () => { + const code = generateGrpcJavaScriptCode(input({ protoFilePath: 'a.proto', auth: bearer })); + expect(code).toContain(`metadata.set('Authorization', 'Bearer abc123');`); + expect(code).toContain('const metadata = new grpc.Metadata();'); + }); + + it('does not overwrite metadata the request already declares', () => { + const command = generateGrpcurlCommand( + input({ auth: bearer, metadata: [{ name: 'authorization', value: 'Bearer mine' }] as GrpcMetadata[] }) + ); + expect(command).toContain(`-H 'authorization: Bearer mine'`); + expect(command).not.toContain('abc123'); + }); + + it('notes auth it cannot express as metadata instead of dropping it silently', () => { + const command = generateGrpcurlCommand(input({ auth: { type: 'awsv4' } as never })); + expect(command).toContain('# auth: awsv4'); + }); + + it('leaves the snippets untouched when there is no auth', () => { + expect(generateGrpcurlCommand(input())).not.toContain('Authorization'); + }); + + it('contains a templated body that appends statements', () => { + const code = generateGrpcJavaScriptCode( + input({ + protoFilePath: 'a.proto', + messages: [{ title: 'a', message: `{"a":"{{t}}"}; require('child_process').execSync('x')` }] + }) + ); + expect(code).toContain(`const message = '{"a":"{{t}}"}`); + expect(code).not.toContain('const message = {"a":"{{t}}"}; require'); + }); + + it('still keeps an ordinary templated body verbatim', () => { + const code = generateGrpcJavaScriptCode( + input({ protoFilePath: 'a.proto', messages: [{ title: 'a', message: '{"n":{{count}}}' }] }) + ); + expect(code).toContain('const message = {"n":{{count}}};'); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/grpcSnippets.ts b/packages/bruno-api-docs/src/utils/grpcSnippets.ts new file mode 100644 index 00000000..f78a0d00 --- /dev/null +++ b/packages/bruno-api-docs/src/utils/grpcSnippets.ts @@ -0,0 +1,211 @@ +import type { GrpcMetadata, GrpcMethodType } from '@opencollection/types/requests/grpc'; +import type { Auth } from '@opencollection/types/common/auth'; +import type { GrpcMessageEntry } from '@/utils/schemaHelpers'; +import { templateVariableGlobalRegex } from '@/utils/common'; +import { authToHeaders } from '@/utils/codeSnippets'; + +export interface GrpcSnippetInput { + url: string; + method: string; + methodType?: GrpcMethodType; + protoFilePath?: string; + metadata: GrpcMetadata[]; + messages: GrpcMessageEntry[]; + resolvedUrl?: string; + auth?: Auth; +} + +const SCHEME_PATTERN = /^(grpcs?|https?):\/\//i; + +const schemeOf = (value: string): string | undefined => value.trim().match(SCHEME_PATTERN)?.[1]?.toLowerCase(); + +const parseTarget = (url: string, resolvedUrl?: string): { target: string; plaintext: boolean } => { + const trimmed = url.trim(); + const scheme = schemeOf(trimmed) ?? schemeOf(resolvedUrl ?? ''); + const plaintext = !scheme || scheme === 'grpc' || scheme === 'http'; + return { target: trimmed.replace(SCHEME_PATTERN, ''), plaintext }; +}; + +export const grpcMethodPath = (method: string): string => method.replace(/^\//, ''); + +const buildProtoFlags = (protoFilePath: string): string[] => { + const normalised = protoFilePath.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); + const lastSlash = normalised.lastIndexOf('/'); + const file = lastSlash === -1 ? normalised : normalised.slice(lastSlash + 1); + const dir = lastSlash === -1 ? '' : normalised.slice(0, lastSlash) || '/'; + return dir ? [`-import-path ${shellQuote(dir)}`, `-proto ${shellQuote(file)}`] : [`-proto ${shellQuote(file)}`]; +}; + +const streamsInFor = (methodType?: GrpcMethodType): boolean => + methodType === 'client-streaming' || methodType === 'bidi-streaming'; + +const streamsOutFor = (methodType?: GrpcMethodType): boolean => + methodType === 'server-streaming' || methodType === 'bidi-streaming'; + +const parseService = (method: string): { servicePath: string; methodName: string } => { + const trimmed = grpcMethodPath(method); + const slash = trimmed.lastIndexOf('/'); + if (slash === -1) return { servicePath: '', methodName: trimmed }; + return { servicePath: trimmed.slice(0, slash), methodName: trimmed.slice(slash + 1) }; +}; + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const IDENTIFIER_PATH = /^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/; + +const effectiveMetadata = ( + metadata: GrpcMetadata[], + auth: Auth | undefined +): { entries: GrpcMetadata[]; note?: string } => { + const entries = metadata.filter((entry) => !entry.disabled); + const { headers, comment } = authToHeaders(auth); + headers.forEach((header) => { + const present = entries.some((entry) => (entry.name || '').toLowerCase() === header.name.toLowerCase()); + if (!present) entries.push({ name: header.name, value: header.value }); + }); + return { entries, note: comment }; +}; + +const heredocDelimiter = (messages: string[]): string => { + const lines = new Set(messages.flatMap((message) => message.split('\n').map((line) => line.trim()))); + let delimiter = 'EOF'; + for (let suffix = 2; lines.has(delimiter); suffix += 1) delimiter = `EOF${suffix}`; + return delimiter; +}; + +const shellQuote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`; + +const jsQuote = (value: string): string => + `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}'`; + +const jsObjectLiteral = (message: string): string => { + const trimmed = message.trim(); + if (!trimmed) return '{}'; + try { + JSON.parse(trimmed.replace(templateVariableGlobalRegex(), '0')); + return trimmed; + } catch { + return jsQuote(trimmed); + } +}; + +const indent = (text: string, spaces: number): string => + text + .split('\n') + .map((line, index) => (index === 0 ? line : `${' '.repeat(spaces)}${line}`)) + .join('\n'); + +export const generateGrpcurlCommand = ({ + url, + method, + methodType, + protoFilePath, + metadata, + messages, + resolvedUrl, + auth +}: GrpcSnippetInput): string => { + const { target, plaintext } = parseTarget(url, resolvedUrl); + const { entries, note } = effectiveMetadata(metadata, auth); + const parts: string[] = ['grpcurl']; + + if (plaintext) { + parts.push('-plaintext'); + } + + for (const entry of entries) { + parts.push(`-H ${shellQuote(`${entry.name}: ${entry.value}`)}`); + } + + if (protoFilePath) { + parts.push(...buildProtoFlags(protoFilePath)); + } + + const streaming = streamsInFor(methodType); + + if (messages.length > 0) { + parts.push(streaming ? '-d @' : `-d ${shellQuote(messages[0].message)}`); + } + + parts.push(shellQuote(target)); + parts.push(shellQuote(grpcMethodPath(method))); + + const command = parts.join(' \\\n '); + const prefix = note ? `# ${note}\n` : ''; + + if (streaming && messages.length > 0) { + const bodies = messages.map((entry) => entry.message); + const delimiter = heredocDelimiter(bodies); + return `${prefix}${command} << '${delimiter}'\n${bodies.join('\n')}\n${delimiter}`; + } + + return `${prefix}${command}`; +}; + +export const generateGrpcJavaScriptCode = ({ + url, + method, + methodType, + protoFilePath, + metadata, + messages, + resolvedUrl, + auth +}: GrpcSnippetInput): string => { + const { servicePath, methodName } = parseService(method); + if (!protoFilePath || !IDENTIFIER_PATH.test(servicePath) || !IDENTIFIER.test(methodName)) return ''; + + const { target, plaintext } = parseTarget(url, resolvedUrl); + const { entries: enabled, note } = effectiveMetadata(metadata, auth); + const credentials = plaintext ? 'grpc.credentials.createInsecure()' : 'grpc.credentials.createSsl()'; + + const lines: string[] = [ + ...(note ? [`// ${note}`] : []), + `const grpc = require('@grpc/grpc-js');`, + `const protoLoader = require('@grpc/proto-loader');`, + '', + `const packageDefinition = protoLoader.loadSync(${jsQuote(protoFilePath)});`, + 'const proto = grpc.loadPackageDefinition(packageDefinition);', + '', + `const client = new proto.${servicePath}(${jsQuote(target)}, ${credentials});` + ]; + + if (enabled.length > 0) { + lines.push('', 'const metadata = new grpc.Metadata();'); + enabled.forEach((entry) => lines.push(`metadata.set(${jsQuote(entry.name)}, ${jsQuote(entry.value)});`)); + } + + const metadataArg = enabled.length > 0 ? 'metadata' : ''; + const streamsIn = streamsInFor(methodType); + const streamsOut = streamsOutFor(methodType); + + if (streamsIn) { + lines.push('', 'const messages = ['); + messages.forEach((entry, index) => { + const comma = index === messages.length - 1 ? '' : ','; + lines.push(` ${indent(jsObjectLiteral(entry.message), 2)}${comma}`); + }); + lines.push('];'); + } else { + lines.push('', `const message = ${messages.length > 0 ? jsObjectLiteral(messages[0].message) : '{}'};`); + } + + lines.push(''); + + const callArgs = [streamsIn ? '' : 'message', metadataArg].filter(Boolean).join(', '); + + const withCallback = `${callArgs}${callArgs ? ', ' : ''}(error, response) => {`; + + if (!streamsOut) { + const opening = streamsIn ? `const call = client.${methodName}(` : `client.${methodName}(`; + lines.push(`${opening}${withCallback}`, ' console.log(error ?? response);', '});'); + } else { + lines.push(`const call = client.${methodName}(${callArgs});`); + lines.push(`call.on('data', (response) => console.log(response));`, `call.on('end', () => console.log('done'));`); + } + + if (streamsIn) { + lines.push('', 'for (const message of messages) {', ' call.write(message);', '}', 'call.end();'); + } + + return lines.join('\n'); +}; diff --git a/packages/bruno-api-docs/src/utils/request.ts b/packages/bruno-api-docs/src/utils/request.ts index 14bd3710..c7b3108f 100644 --- a/packages/bruno-api-docs/src/utils/request.ts +++ b/packages/bruno-api-docs/src/utils/request.ts @@ -1,5 +1,6 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item } from '@opencollection/types/collection/item'; +import type { GrpcRequest } from '@opencollection/types/requests/grpc'; import type { HttpRequest, HttpRequestBody, @@ -20,7 +21,8 @@ import { getRequestScripts, scriptsArrayToObject, getRequestVariables, - getHttpHeaders + getHttpHeaders, + type RequestItem } from './schemaHelpers'; import { getItemUuid } from './itemUtils'; import { isSecretVariable, unwrapVariableValue } from './variableResolution'; @@ -89,7 +91,7 @@ export const resolveInheritedAuth = ( ancestors: Item[], item: Item ): ResolvedAuth => { - const own = getRequestAuth(item as HttpRequest) as Auth | undefined; + const own = getRequestAuth(item as RequestItem) as Auth | undefined; if (own !== 'inherit') return { auth: own }; // Walk ancestors leaf->root. Only an `inherit` folder is transparent; the first folder that @@ -128,7 +130,7 @@ export const getInheritedAuthSummary = ( ancestors: Item[], item: Item ): InheritedAuthSummary | null => { - if (getRequestAuth(item as HttpRequest) !== 'inherit') return null; + if (getRequestAuth(item as RequestItem) !== 'inherit') return null; const resolved = resolveInheritedAuth(collection, ancestors, item); return { sourceName: resolved.source?.name || collection?.info?.name || 'Collection', @@ -275,7 +277,7 @@ const stepLabel = (level: ScriptLevel, phase: ScriptPhase): string => { export const buildScriptChain = ( collection: OpenCollection | null | undefined, ancestors: Item[], - item: HttpRequest + item: HttpRequest | GrpcRequest ): ScriptChainStep[] => { const collectionScripts = scriptsArrayToObject(collection?.request?.scripts); const sources: ScriptSource[] = [ @@ -342,11 +344,13 @@ const toPostResponseVarRow = (action: Action): PostResponseVarRow => ({ disabled: action.disabled }); -export const getPreRequestVars = (item: HttpRequest): PreRequestVarRow[] => +export const getPreRequestVars = (item: HttpRequest | GrpcRequest): PreRequestVarRow[] => getRequestVariables(item).map(toPreRequestVarRow); -export const getPostResponseVars = (item: HttpRequest): PostResponseVarRow[] => - (item.runtime?.actions ?? []).filter(isAfterResponseSetVariable).map(toPostResponseVarRow); +export const getPostResponseVars = (item: HttpRequest | GrpcRequest): PostResponseVarRow[] => + ((item as { runtime?: { actions?: Action[] } }).runtime?.actions ?? []) + .filter(isAfterResponseSetVariable) + .map(toPostResponseVarRow); // Bridge the OC actions model (after-response set-variable) to the editable Variables rows, and back. export const actionsToPostResponseVars = (actions: Action[] = []): PostResponseVar[] => diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts index d78fc2ae..06788d6a 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.spec.ts @@ -1,11 +1,20 @@ import { describe, it, expect } from 'vitest'; import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; -import { getItemDescription, getRequestBadgeLabel, getRequestAuth } from './schemaHelpers'; +import { + getItemDescription, + getRequestBadgeLabel, + getRequestAuth, + getGrpcMessages, + getGrpcMethod, + getGrpcMethodType, + getGrpcMetadata, + getGrpcProtoFileName, + type RequestItem +} from './schemaHelpers'; const item = (data: Record): OpenCollectionItem => data as unknown as OpenCollectionItem; -// getRequestAuth accepts only request items (not folders); cast fabricated shapes to its param type. -const requestItem = (data: Record) => data as unknown as Parameters[0]; +const requestItem = (data: Record): RequestItem => data as unknown as RequestItem; describe('getItemDescription', () => { it('reads a plain string description from the info block', () => { @@ -68,3 +77,124 @@ describe('getRequestAuth', () => { expect(getRequestAuth(requestItem({ method: 'POST', request: { auth: undefined } }))).toBeUndefined(); }); }); + +describe('getGrpcMethod', () => { + it('reads the method from the grpc block', () => { + expect(getGrpcMethod(item({ grpc: { method: '/hello.HelloService/SayHello' } }))).toBe( + '/hello.HelloService/SayHello' + ); + }); + + it('returns an empty string when there is no method or no grpc block', () => { + expect(getGrpcMethod(item({ grpc: {} }))).toBe(''); + expect(getGrpcMethod(item({ type: 'grpc', url: 'grpc://localhost:50051' }))).toBe(''); + expect(getGrpcMethod(null)).toBe(''); + }); +}); + +describe('getGrpcMethodType', () => { + it('reads the method type from the grpc block', () => { + expect(getGrpcMethodType(item({ grpc: { methodType: 'bidi-streaming' } }))).toBe('bidi-streaming'); + }); + + it('returns undefined when the method type is absent', () => { + expect(getGrpcMethodType(item({ grpc: {} }))).toBeUndefined(); + expect(getGrpcMethodType(item({ type: 'grpc' }))).toBeUndefined(); + }); +}); + +describe('getGrpcMetadata', () => { + it('reads metadata rows, keeping descriptions and disabled flags', () => { + expect( + getGrpcMetadata( + item({ + grpc: { + metadata: [ + { name: 'authorization', value: 'Bearer t', description: 'Auth token' }, + { name: 'x-legacy', value: 'off', disabled: true } + ] + } + }) + ) + ).toEqual([ + { name: 'authorization', value: 'Bearer t', description: 'Auth token' }, + { name: 'x-legacy', value: 'off', disabled: true } + ]); + }); + + it('returns an empty list when there is no metadata', () => { + expect(getGrpcMetadata(item({ grpc: {} }))).toEqual([]); + expect(getGrpcMetadata(item({ type: 'grpc' }))).toEqual([]); + }); +}); + +describe('getGrpcMessages', () => { + it('wraps a single stored string as one numbered message', () => { + expect(getGrpcMessages(item({ grpc: { message: '{"a":1}' } }))).toEqual([ + { title: 'Message 1', message: '{"a":1}' } + ]); + }); + + it('reads a one-item list identically to a stored string', () => { + expect(getGrpcMessages(item({ grpc: { message: [{ title: 'message 1', message: '{"a":1}' }] } }))).toEqual([ + { title: 'message 1', message: '{"a":1}' } + ]); + }); + + it('keeps the order of a streaming message list', () => { + expect( + getGrpcMessages( + item({ + grpc: { + message: [ + { title: 'message 1', message: '{"greeting":"sortitus"}' }, + { title: 'message 2', message: '{"greeting":"porro"}' } + ] + } + }) + ) + ).toEqual([ + { title: 'message 1', message: '{"greeting":"sortitus"}' }, + { title: 'message 2', message: '{"greeting":"porro"}' } + ]); + }); + + it('numbers entries that have no title, using the stored position', () => { + expect(getGrpcMessages(item({ grpc: { message: [{ message: 'a' }, { title: '', message: 'b' }] } }))).toEqual([ + { title: 'Message 1', message: 'a' }, + { title: 'Message 2', message: 'b' } + ]); + }); + + it('drops blank messages but keeps the numbering of the ones that remain', () => { + expect(getGrpcMessages(item({ grpc: { message: ' ' } }))).toEqual([]); + expect(getGrpcMessages(item({ grpc: { message: [{ message: '' }, { message: 'b' }] } }))).toEqual([ + { title: 'Message 2', message: 'b' } + ]); + }); + + it('returns an empty list when there is no message or no grpc block', () => { + expect(getGrpcMessages(item({ grpc: {} }))).toEqual([]); + expect(getGrpcMessages(item({ type: 'grpc' }))).toEqual([]); + expect(getGrpcMessages(null)).toEqual([]); + }); +}); + +describe('getGrpcProtoFileName', () => { + it('returns just the file name from a stored path', () => { + expect(getGrpcProtoFileName(item({ grpc: { protoFilePath: 'protos/hello.proto' } }))).toBe('hello.proto'); + }); + + it('handles a path that climbs out of the collection folder', () => { + expect(getGrpcProtoFileName(item({ grpc: { protoFilePath: '../../Downloads/book.proto' } }))).toBe('book.proto'); + }); + + it('handles a windows-style path', () => { + expect(getGrpcProtoFileName(item({ grpc: { protoFilePath: 'protos\\book.proto' } }))).toBe('book.proto'); + }); + + it('returns undefined when no proto file is attached', () => { + expect(getGrpcProtoFileName(item({ grpc: {} }))).toBeUndefined(); + expect(getGrpcProtoFileName(item({ type: 'grpc' }))).toBeUndefined(); + }); +}); diff --git a/packages/bruno-api-docs/src/utils/schemaHelpers.ts b/packages/bruno-api-docs/src/utils/schemaHelpers.ts index 1e90629c..28066e4c 100644 --- a/packages/bruno-api-docs/src/utils/schemaHelpers.ts +++ b/packages/bruno-api-docs/src/utils/schemaHelpers.ts @@ -15,12 +15,12 @@ import type { OpenCollection } from '@opencollection/types'; import type { Item as OpenCollectionItem, Folder, ScriptFile } from '@opencollection/types/collection/item'; import type { HttpRequest, HttpRequestHeader, HttpRequestExample, HttpRequestBody, HttpRequestBodyVariant } from '@opencollection/types/requests/http'; import type { GraphQLRequest } from '@opencollection/types/requests/graphql'; -import type { GrpcRequest } from '@opencollection/types/requests/grpc'; +import type { GrpcRequest, GrpcRequestDetails, GrpcMetadata, GrpcMethodType } from '@opencollection/types/requests/grpc'; import type { WebSocketRequest } from '@opencollection/types/requests/websocket'; import type { Script, Scripts, ScriptType } from '@opencollection/types/common/scripts'; import { PROTOCOL_BADGE_LABELS } from '../constants'; -type RequestItem = HttpRequest | GraphQLRequest | GrpcRequest | WebSocketRequest; +export type RequestItem = HttpRequest | GraphQLRequest | GrpcRequest | WebSocketRequest; /** A request body as stored on an item: a single body, a list of body variants, or none. */ export type RequestBody = HttpRequestBody | HttpRequestBodyVariant[] | undefined; @@ -119,8 +119,13 @@ export const isWebSocketRequest = (item: OpenCollectionItem | null | undefined): return getItemType(item) === 'websocket'; }; -// Check if an item is a request the docs viewer can't render (GraphQL, gRPC or WebSocket). -export const isUnsupportedRequest = ( +export const isUnsupportedRequestInDocs = ( + item: OpenCollectionItem | null | undefined +): item is GraphQLRequest | WebSocketRequest => { + return isGraphQLRequest(item) || isWebSocketRequest(item); +}; + +export const isUnsupportedRequestInPlayground = ( item: OpenCollectionItem | null | undefined ): item is GraphQLRequest | GrpcRequest | WebSocketRequest => { return isGraphQLRequest(item) || isGrpcRequest(item) || isWebSocketRequest(item); @@ -186,6 +191,52 @@ export const getRequestUrl = (item: RequestItem | null | undefined): string => { return ''; }; +export interface GrpcMessageEntry { + title: string; + message: string; +} + +const getGrpcDetails = (item: OpenCollectionItem | null | undefined): GrpcRequestDetails => + (item && 'grpc' in item ? (item as GrpcRequest).grpc : undefined) ?? {}; + +export const getGrpcMethod = (item: OpenCollectionItem | null | undefined): string => + getGrpcDetails(item).method ?? ''; + +export const getGrpcMethodType = (item: OpenCollectionItem | null | undefined): GrpcMethodType | undefined => + getGrpcDetails(item).methodType; + +export const getGrpcMetadata = (item: OpenCollectionItem | null | undefined): GrpcMetadata[] => + getGrpcDetails(item).metadata ?? []; + +export const getGrpcMessages = (item: OpenCollectionItem | null | undefined): GrpcMessageEntry[] => { + const message = getGrpcDetails(item).message; + + if (typeof message === 'string') { + return message.trim() ? [{ title: 'Message 1', message }] : []; + } + + if (Array.isArray(message)) { + return message + .map((variant, index) => ({ + title: variant.title || `Message ${index + 1}`, + message: variant.message ?? '' + })) + .filter((entry) => entry.message.trim().length > 0); + } + + return []; +}; + +export const getGrpcProtoFilePath = (item: OpenCollectionItem | null | undefined): string | undefined => + getGrpcDetails(item).protoFilePath || undefined; + +export const getGrpcProtoFileName = (item: OpenCollectionItem | null | undefined): string | undefined => { + const protoFilePath = getGrpcDetails(item).protoFilePath; + if (!protoFilePath) return undefined; + const segments = protoFilePath.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1]; +}; + /** * Get headers from an HTTP request (from http block or root) */