diff --git a/app/app/docs/components/SwaggerUI.tsx b/app/app/docs/components/SwaggerUI.tsx index d147e144..91d45e9c 100644 --- a/app/app/docs/components/SwaggerUI.tsx +++ b/app/app/docs/components/SwaggerUI.tsx @@ -4,6 +4,17 @@ import dynamic from 'next/dynamic'; import { useEffect, useState } from 'react'; import 'swagger-ui-react/swagger-ui.css'; +import { + DEFAULT_PLAYGROUND_SETTINGS, + SWAGGER_PLAYGROUND_STORAGE_KEY, + type SwaggerPlaygroundSettings, + type SwaggerRequest, + applyPlaygroundSettings, + parseStoredPlaygroundSettings, + serializePlaygroundSettings, +} from './swagger-playground'; +import { createSwaggerRbacPlugin } from './swagger-rbac'; + // Dynamically import swagger-ui-react to avoid SSR issues const SwaggerUIReact = dynamic(() => import('swagger-ui-react'), { ssr: false, @@ -18,13 +29,57 @@ interface SwaggerUIProps { specUrl?: string; } +const swaggerRbacPlugin = createSwaggerRbacPlugin(); +const SUPPORTED_SUBMIT_METHODS: Array<'delete' | 'get' | 'head' | 'options' | 'patch' | 'post' | 'put'> = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'head', + 'options', +]; +const PLAYGROUND_APPLY_DEBOUNCE_MS = 350; + export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIProps) { const [mounted, setMounted] = useState(false); + const [playground, setPlayground] = useState(DEFAULT_PLAYGROUND_SETTINGS); + const [appliedPlayground, setAppliedPlayground] = useState(DEFAULT_PLAYGROUND_SETTINGS); useEffect(() => { setMounted(true); + + const storedSettings = window.localStorage.getItem(SWAGGER_PLAYGROUND_STORAGE_KEY); + const parsedSettings = parseStoredPlaygroundSettings(storedSettings); + setPlayground(parsedSettings); + setAppliedPlayground(parsedSettings); }, []); + useEffect(() => { + if (!mounted) { + return; + } + + window.localStorage.setItem(SWAGGER_PLAYGROUND_STORAGE_KEY, serializePlaygroundSettings(playground)); + }, [mounted, playground]); + + useEffect(() => { + if (!mounted) { + return; + } + + const timeoutId = window.setTimeout(() => { + setAppliedPlayground(playground); + }, PLAYGROUND_APPLY_DEBOUNCE_MS); + + return () => window.clearTimeout(timeoutId); + }, [mounted, playground]); + + const isApplyingPlaygroundSettings = + playground.enabled !== appliedPlayground.enabled || + playground.baseUrl !== appliedPlayground.baseUrl || + playground.apiKey !== appliedPlayground.apiKey; + if (!mounted) { return (
@@ -37,6 +92,84 @@ export default function SwaggerUI({ specUrl = '/docs/openapi.json' }: SwaggerUIP
- +
+
+
Playground Mode
+ +
+

+ Route requests to your backend and inject your raw Archestra API key into the Authorization{' '} + header. Do not prefix the key with Bearer. +

+
+
+ + + setPlayground((current) => ({ + ...current, + baseUrl: event.target.value, + })) + } + placeholder="http://localhost:9000" + type="text" + value={playground.baseUrl} + /> +
+
+ + + setPlayground((current) => ({ + ...current, + apiKey: event.target.value, + })) + } + placeholder="Paste API key" + type="password" + value={playground.apiKey} + /> +
+
+

+ Try it out is enabled only while playground mode is on. Settings stay in your browser on this machine. +

+ {isApplyingPlaygroundSettings ? ( +

Updating playground…

+ ) : null} +
+ + applyPlaygroundSettings({ + request: request as SwaggerRequest, + settings: appliedPlayground, + specUrl, + }) + } + supportedSubmitMethods={appliedPlayground.enabled ? SUPPORTED_SUBMIT_METHODS : []} + tryItOutEnabled={appliedPlayground.enabled} + />
); } diff --git a/app/app/docs/components/swagger-playground.test.ts b/app/app/docs/components/swagger-playground.test.ts new file mode 100644 index 00000000..a42296c1 --- /dev/null +++ b/app/app/docs/components/swagger-playground.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_PLAYGROUND_SETTINGS, + applyPlaygroundSettings, + parseStoredPlaygroundSettings, + rewriteRequestUrl, + serializePlaygroundSettings, +} from './swagger-playground'; + +describe('rewriteRequestUrl', () => { + it('rewrites absolute request URLs to the configured base URL', () => { + expect( + rewriteRequestUrl({ + baseUrl: 'http://localhost:9000/', + requestUrl: 'http://localhost:3001/api/tools?limit=10', + }) + ).toBe('http://localhost:9000/api/tools?limit=10'); + }); + + it('rewrites relative request URLs to the configured base URL', () => { + expect( + rewriteRequestUrl({ + baseUrl: 'http://localhost:9000', + requestUrl: '/api/tools', + }) + ).toBe('http://localhost:9000/api/tools'); + }); + + it('adds http:// when the configured base URL is missing a scheme', () => { + expect( + rewriteRequestUrl({ + baseUrl: 'localhost:9000', + requestUrl: '/api/tools', + }) + ).toBe('http://localhost:9000/api/tools'); + }); +}); + +describe('applyPlaygroundSettings', () => { + it('rewrites operation requests and injects the raw Authorization header', () => { + expect( + applyPlaygroundSettings({ + request: { + headers: { + Accept: 'application/json', + }, + url: 'http://localhost:3001/api/tools', + }, + settings: { + ...DEFAULT_PLAYGROUND_SETTINGS, + apiKey: 'test-api-key', + baseUrl: 'http://localhost:9000', + enabled: true, + }, + specUrl: '/docs/openapi.json', + }) + ).toEqual({ + headers: { + Accept: 'application/json', + Authorization: 'test-api-key', + }, + url: 'http://localhost:9000/api/tools', + }); + }); + + it('leaves the spec fetch request alone', () => { + expect( + applyPlaygroundSettings({ + request: { + url: 'http://localhost:3001/docs/openapi.json', + }, + settings: { + ...DEFAULT_PLAYGROUND_SETTINGS, + enabled: true, + }, + specUrl: '/docs/openapi.json', + }) + ).toEqual({ + url: 'http://localhost:3001/docs/openapi.json', + }); + }); +}); + +describe('parseStoredPlaygroundSettings', () => { + it('falls back to defaults when storage is empty or invalid', () => { + expect(parseStoredPlaygroundSettings(null)).toEqual(DEFAULT_PLAYGROUND_SETTINGS); + expect(parseStoredPlaygroundSettings('{')).toEqual(DEFAULT_PLAYGROUND_SETTINGS); + }); + + it('drops any previously persisted apiKey value', () => { + expect( + parseStoredPlaygroundSettings( + JSON.stringify({ + apiKey: 'old-secret', + baseUrl: 'http://localhost:9000', + enabled: true, + }) + ) + ).toEqual({ + apiKey: '', + baseUrl: 'http://localhost:9000', + enabled: true, + }); + }); +}); + +describe('serializePlaygroundSettings', () => { + it('persists only non-sensitive playground settings', () => { + expect( + serializePlaygroundSettings({ + apiKey: 'secret-key', + baseUrl: 'http://localhost:9000', + enabled: true, + }) + ).toBe( + JSON.stringify({ + baseUrl: 'http://localhost:9000', + enabled: true, + }) + ); + }); +}); diff --git a/app/app/docs/components/swagger-playground.ts b/app/app/docs/components/swagger-playground.ts new file mode 100644 index 00000000..31172735 --- /dev/null +++ b/app/app/docs/components/swagger-playground.ts @@ -0,0 +1,152 @@ +'use client'; + +// === Exports === + +const DEFAULT_PLAYGROUND_BASE_URL = 'http://localhost:9000'; + +export const DEFAULT_PLAYGROUND_SETTINGS: SwaggerPlaygroundSettings = { + apiKey: '', + baseUrl: DEFAULT_PLAYGROUND_BASE_URL, + enabled: false, +}; + +export const SWAGGER_PLAYGROUND_STORAGE_KEY = 'archestra-docs-swagger-playground'; + +export type SwaggerPlaygroundSettings = { + apiKey: string; + baseUrl: string; + enabled: boolean; +}; + +export type SwaggerRequest = { + headers?: Record; + loadSpec?: boolean; + url?: string; +}; + +export function applyPlaygroundSettings(params: { + request: SwaggerRequest; + settings: SwaggerPlaygroundSettings; + specUrl: string; +}): SwaggerRequest { + const { request, settings, specUrl } = params; + if (!settings.enabled || !request.url || request.loadSpec) { + return request; + } + + if (isSpecRequest(request.url, specUrl)) { + return request; + } + + const rewrittenUrl = rewriteRequestUrl({ + baseUrl: settings.baseUrl, + requestUrl: request.url, + }); + if (!rewrittenUrl) { + return request; + } + + const headers = { + ...(request.headers ?? {}), + }; + const apiKey = settings.apiKey.trim(); + if (apiKey) { + headers.Authorization = apiKey; + } + + return { + ...request, + headers, + url: rewrittenUrl, + }; +} + +export function parseStoredPlaygroundSettings(value: string | null): SwaggerPlaygroundSettings { + if (!value) { + return DEFAULT_PLAYGROUND_SETTINGS; + } + + try { + const parsed = JSON.parse(value); + if (!isPlaygroundSettings(parsed)) { + return DEFAULT_PLAYGROUND_SETTINGS; + } + + return { + apiKey: '', + baseUrl: parsed.baseUrl || DEFAULT_PLAYGROUND_BASE_URL, + enabled: parsed.enabled, + }; + } catch { + return DEFAULT_PLAYGROUND_SETTINGS; + } +} + +export function serializePlaygroundSettings(settings: SwaggerPlaygroundSettings): string { + return JSON.stringify({ + baseUrl: settings.baseUrl, + enabled: settings.enabled, + }); +} + +export function rewriteRequestUrl(params: { baseUrl: string; requestUrl: string }): string | null { + const normalizedBaseUrl = normalizeBaseUrl(params.baseUrl); + if (!normalizedBaseUrl) { + return null; + } + + const parsedUrl = tryParseUrl(params.requestUrl); + if (parsedUrl) { + return `${normalizedBaseUrl}${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; + } + + if (params.requestUrl.startsWith('/')) { + return `${normalizedBaseUrl}${params.requestUrl}`; + } + + return `${normalizedBaseUrl}/${params.requestUrl.replace(/^\/+/, '')}`; +} + +// === Internal helpers === + +function isSpecRequest(requestUrl: string, specUrl: string): boolean { + const parsedUrl = tryParseUrl(requestUrl); + const pathname = parsedUrl?.pathname ?? requestUrl; + + return pathname === specUrl || pathname.endsWith(specUrl); +} + +function normalizeBaseUrl(baseUrl: string): string { + const trimmedBaseUrl = baseUrl.trim(); + if (!trimmedBaseUrl) { + return ''; + } + + const baseUrlWithScheme = /^[a-z]+:\/\//i.test(trimmedBaseUrl) ? trimmedBaseUrl : `http://${trimmedBaseUrl}`; + + return baseUrlWithScheme.replace(/\/+$/, ''); +} + +function tryParseUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +function isPlaygroundSettings( + value: unknown +): value is Pick & { apiKey?: string } { + if (!value || typeof value !== 'object') { + return false; + } + + const record = value as Record; + + return ( + typeof record.enabled === 'boolean' && + typeof record.baseUrl === 'string' && + (record.apiKey === undefined || typeof record.apiKey === 'string') + ); +} diff --git a/app/app/docs/components/swagger-rbac.test.tsx b/app/app/docs/components/swagger-rbac.test.tsx new file mode 100644 index 00000000..d85aeaf5 --- /dev/null +++ b/app/app/docs/components/swagger-rbac.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { SwaggerRbacPermissions, extractRequiredPermissions } from './swagger-rbac'; + +describe('extractRequiredPermissions', () => { + it('reads x-required-permissions from plain objects', () => { + expect( + extractRequiredPermissions({ + operationProps: { + op: { + 'x-required-permissions': { + permissions: ['toolPolicy:read', 'team:update'], + note: 'Checked dynamically', + }, + }, + }, + }) + ).toEqual({ + note: 'Checked dynamically', + permissions: ['toolPolicy:read', 'team:update'], + }); + }); + + it('reads x-required-permissions from immutable-style get() records', () => { + const makeRecord = (value: Record) => ({ + get: (key: string) => value[key], + }); + + expect( + extractRequiredPermissions({ + operationProps: makeRecord({ + op: makeRecord({ + 'x-required-permissions': makeRecord({ + permissions: { + toJS: () => ['mcpRegistry:read'], + }, + }), + }), + }), + }) + ).toEqual({ + note: undefined, + permissions: ['mcpRegistry:read'], + }); + }); +}); + +describe('SwaggerRbacPermissions', () => { + it('renders permission badges', () => { + render(); + + expect(screen.getByTestId('swagger-rbac-permissions')).toBeInTheDocument(); + expect(screen.getByText('RBAC')).toBeInTheDocument(); + expect(screen.getByText('toolPolicy:read')).toBeInTheDocument(); + }); + + it('renders note-only RBAC metadata', () => { + render( + + ); + + expect(screen.getByText('None (no additional RBAC permission required)')).toBeInTheDocument(); + }); + + it('renders nothing when there are no permissions', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/app/app/docs/components/swagger-rbac.tsx b/app/app/docs/components/swagger-rbac.tsx new file mode 100644 index 00000000..4e190012 --- /dev/null +++ b/app/app/docs/components/swagger-rbac.tsx @@ -0,0 +1,101 @@ +'use client'; + +// === Exports === + +type SwaggerRbacMetadata = { + note?: string; + permissions: string[]; +}; + +export function createSwaggerRbacPlugin() { + return () => ({}); +} + +export function extractRequiredPermissions(operationSummaryProps: unknown): SwaggerRbacMetadata { + const operationProps = getRecordValue(operationSummaryProps, 'operationProps'); + const operation = getRecordValue(operationProps, 'op'); + const extension = getRecordValue(operation, 'x-required-permissions'); + const permissions = getRecordValue(extension, 'permissions'); + const note = getStringValue(getRecordValue(extension, 'note')); + + if (Array.isArray(permissions)) { + return { + note, + permissions: permissions.filter((value): value is string => typeof value === 'string'), + }; + } + + if (hasToJs(permissions)) { + const values = permissions.toJS(); + if (Array.isArray(values)) { + return { + note, + permissions: values.filter((value): value is string => typeof value === 'string'), + }; + } + } + + return { + note, + permissions: [], + }; +} + +export function SwaggerRbacPermissions({ metadata }: { metadata: SwaggerRbacMetadata }) { + if (metadata.permissions.length === 0 && !metadata.note) { + return null; + } + + return ( +
+ RBAC +
+ {metadata.permissions.length > 0 ? ( +
+ {metadata.permissions.map((permission) => ( + + {permission} + + ))} +
+ ) : null} + {metadata.note ? {metadata.note} : null} +
+
+ ); +} + +// === Internal helpers === + +function getRecordValue(source: unknown, key: string): unknown { + if (!source) { + return undefined; + } + + if (typeof source === 'object' && source !== null && key in source) { + return (source as Record)[key]; + } + + if ( + typeof source === 'object' && + source !== null && + 'get' in source && + typeof (source as { get?: unknown }).get === 'function' + ) { + return (source as { get: (value: string) => unknown }).get(key); + } + + return undefined; +} + +function hasToJs(value: unknown): value is { toJS: () => unknown } { + return typeof value === 'object' && value !== null && 'toJS' in value && typeof value.toJS === 'function'; +} + +function getStringValue(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + + return undefined; +}