diff --git a/packages/chronicle/src/components/api/api-code-snippet.module.css b/packages/chronicle/src/components/api/api-code-snippet.module.css new file mode 100644 index 00000000..1f6fd15a --- /dev/null +++ b/packages/chronicle/src/components/api/api-code-snippet.module.css @@ -0,0 +1,23 @@ +.container { + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + overflow: hidden; + width: 100%; +} + +.header { + justify-content: space-between; +} + +.title { + font-size: var(--rs-font-size-regular); + font-weight: var(--rs-font-weight-regular); + line-height: var(--rs-line-height-regular); + letter-spacing: var(--rs-letter-spacing-regular); + color: var(--rs-color-foreground-base-primary); + white-space: nowrap; +} + +.body { + background: var(--rs-color-background-base-primary); +} diff --git a/packages/chronicle/src/components/api/api-code-snippet.tsx b/packages/chronicle/src/components/api/api-code-snippet.tsx new file mode 100644 index 00000000..1e7a7615 --- /dev/null +++ b/packages/chronicle/src/components/api/api-code-snippet.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useMemo, useState } from 'react' +import { CodeBlock, Flex } from '@raystack/apsara' +import { + generateCurl, + generatePython, + generateGo, + generateTypeScript, +} from '@/lib/snippet-generators' +import styles from './api-code-snippet.module.css' + +interface ApiCodeSnippetProps { + title: string + method: string + url: string + headers: Record + body?: string +} + +const languages = [ + { value: 'curl', label: 'cURL', lang: 'bash', generate: generateCurl }, + { value: 'python', label: 'Python', lang: 'python', generate: generatePython }, + { value: 'go', label: 'Go', lang: 'go', generate: generateGo }, + { value: 'typescript', label: 'TypeScript', lang: 'typescript', generate: generateTypeScript }, +] + +export function ApiCodeSnippet({ title, method, url, headers, body }: ApiCodeSnippetProps) { + const [selected, setSelected] = useState('curl') + const current = languages.find((l) => l.value === selected) ?? languages[0] + + const code = useMemo( + () => current.generate({ method, url, headers, body }), + [current.generate, method, url, headers, body], + ) + + return ( + + + {title} + + + + + {languages.map((l) => ( + + {l.label} + + ))} + + + + + + + {code} + + + ) +} diff --git a/packages/chronicle/src/components/api/api-field-list.module.css b/packages/chronicle/src/components/api/api-field-list.module.css new file mode 100644 index 00000000..bf916457 --- /dev/null +++ b/packages/chronicle/src/components/api/api-field-list.module.css @@ -0,0 +1,62 @@ +.sectionTitle { + font-size: var(--rs-font-size-large); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-large); + letter-spacing: var(--rs-letter-spacing-large); + color: var(--rs-color-foreground-base-primary); +} + +.fieldItem { + padding-bottom: var(--rs-space-5); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); +} + +.fieldItem:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.fieldType { + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + color: var(--rs-color-foreground-base-secondary); +} + +.fieldDescription { + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + color: var(--rs-color-foreground-base-secondary); +} + +.statusDescription { + font-size: var(--rs-font-size-regular); + line-height: var(--rs-line-height-regular); + color: var(--rs-color-foreground-base-primary); +} + +.expandButton { + padding: var(--rs-space-3) var(--rs-space-4); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + background: var(--rs-color-background-base-secondary); + cursor: pointer; + width: 100%; + color: var(--rs-color-foreground-base-primary); +} + +.expandButton:hover { + background: var(--rs-color-background-neutral-secondary); +} + +.expandLabel { + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + color: var(--rs-color-foreground-base-primary); +} + +.childFields { + padding-left: var(--rs-space-5); + margin-top: var(--rs-space-3); +} diff --git a/packages/chronicle/src/components/api/api-field-list.tsx b/packages/chronicle/src/components/api/api-field-list.tsx new file mode 100644 index 00000000..7a2876c8 --- /dev/null +++ b/packages/chronicle/src/components/api/api-field-list.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useState, type ReactNode } from 'react' +import { Badge, Flex } from '@raystack/apsara' +import { ChevronRightIcon, ChevronDownIcon } from '@radix-ui/react-icons' +import type { SchemaField } from '@/lib/schema' +import styles from './api-field-list.module.css' + +interface ApiFieldSectionProps { + title: string + fields: SchemaField[] + headerRight?: ReactNode + description?: string +} + +export function ApiFieldSection({ title, fields, headerRight, description }: ApiFieldSectionProps) { + if (fields.length === 0 && !description) return null + + return ( + + + {title} + {headerRight && ( + + {headerRight} + + )} + + {description && {description}} + + {fields.map((field) => ( + + ))} + + + ) +} + +function FieldItem({ field }: { field: SchemaField }) { + const hasChildren = field.children && field.children.length > 0 + + return ( + + + {field.name} + {field.type} + + {field.description && ( + {field.description} + )} + {hasChildren && } + + ) +} + +function ExpandableChildren({ field }: { field: SchemaField }) { + const [expanded, setExpanded] = useState(false) + + return ( + + setExpanded(!expanded)} + role="button" + tabIndex={0} + > + + {expanded ? 'Hide' : 'Show'} child attributes + + {expanded ? ( + + ) : ( + + )} + + {expanded && ( + + {field.children!.map((child) => ( + + ))} + + )} + + ) +} diff --git a/packages/chronicle/src/components/api/api-overview.module.css b/packages/chronicle/src/components/api/api-overview.module.css new file mode 100644 index 00000000..1b3917df --- /dev/null +++ b/packages/chronicle/src/components/api/api-overview.module.css @@ -0,0 +1,65 @@ +.layout { + align-items: flex-start; + justify-content: space-between; + padding-left: var(--rs-space-9); + padding-right: var(--rs-space-9); + width: 100%; +} + +.left { + min-width: 0; + flex: 0 1 545px; +} + +.right { + min-width: 376px; + max-width: 500px; + width: 100%; +} + +.title { + font-family: var(--rs-font-title); + font-size: var(--rs-font-size-t3); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-t3); + letter-spacing: var(--rs-letter-spacing-t3); + color: var(--rs-color-foreground-base-primary); + margin: 0; +} + +.description { + font-size: var(--rs-font-size-regular); + line-height: var(--rs-line-height-regular); + letter-spacing: var(--rs-letter-spacing-regular); + color: var(--rs-color-foreground-base-secondary); + margin: 0; +} + +.methodBar { + padding: var(--rs-space-3) 0; + border-radius: var(--rs-radius-2); +} + +.path { + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-mono-regular); + line-height: var(--rs-line-height-regular); + color: var(--rs-color-foreground-base-primary); +} + +.divider { + padding: 0; + margin: var(--rs-space-2) 0; +} + +@media (max-width: 1100px) { + .layout { + flex-direction: column; + gap: var(--rs-space-9); + } + + .left, + .right { + width: 100%; + } +} diff --git a/packages/chronicle/src/components/api/api-overview.tsx b/packages/chronicle/src/components/api/api-overview.tsx new file mode 100644 index 00000000..b92a8098 --- /dev/null +++ b/packages/chronicle/src/components/api/api-overview.tsx @@ -0,0 +1,216 @@ +'use client' + +import { useState } from 'react' +import type { OpenAPIV3 } from 'openapi-types' +import { Flex, Button, Menu, CopyButton, Separator } from '@raystack/apsara' +import { ChevronDownIcon } from '@radix-ui/react-icons' +import { MethodBadge } from '@/components/api/method-badge' +import { ApiCodeSnippet } from './api-code-snippet' +import { ApiResponsePanel } from './api-response-panel' +import { flattenSchema, generateExampleJson, type SchemaField } from '@/lib/schema' +import { ApiFieldSection } from './api-field-list' +import { toKind } from '@/lib/schema' +import styles from './api-overview.module.css' + +interface ApiOverviewProps { + method: string + path: string + operation: OpenAPIV3.OperationObject + serverUrl: string + specName: string + auth?: { type: string; header: string; placeholder?: string } +} + +export function ApiOverview({ method, path, operation, auth }: ApiOverviewProps) { + const params = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[] + const body = getRequestBody(operation.requestBody as OpenAPIV3.RequestBodyObject | undefined) + + const headerFields = paramsToFields(params.filter((p) => p.in === 'header')) + const pathFields = paramsToFields(params.filter((p) => p.in === 'path')) + const queryFields = paramsToFields(params.filter((p) => p.in === 'query')) + const responses = getResponseSections(operation.responses as Record) + + const authFields: SchemaField[] = auth + ? [{ name: auth.header, type: 'String', kind: 'string' as const, required: false }] + : headerFields.length > 0 + ? headerFields + : [] + + const fullUrl = '{domain}' + path + const snippetHeaders: Record = {} + if (auth) snippetHeaders[auth.header] = auth.placeholder ?? 'YOUR_API_KEY' + if (body) snippetHeaders['Content-Type'] = body.contentType ?? 'application/json' + + + const hasSections = authFields.length > 0 || pathFields.length > 0 || + queryFields.length > 0 || (body && body.fields.length > 0) || responses.length > 0 + + return ( + + + + + {operation.summary && ( +

{operation.summary}

+ )} + {operation.description && ( +

{operation.description}

+ )} +
+ + + {path} + + +
+ + {hasSections && ( + + {authFields.length > 0 && ( + + )} + + {authFields.length > 0 && (queryFields.length > 0 || pathFields.length > 0 || (body && body.fields.length > 0) || responses.length > 0) && ( + + )} + + {pathFields.length > 0 && ( + + )} + + {pathFields.length > 0 && (queryFields.length > 0 || (body && body.fields.length > 0) || responses.length > 0) && ( + + )} + + {queryFields.length > 0 && ( + + )} + + {queryFields.length > 0 && ((body && body.fields.length > 0) || responses.length > 0) && ( + + )} + + {body && body.fields.length > 0 && ( + + )} + + {body && body.fields.length > 0 && responses.length > 0 && ( + + )} + + {responses.length > 0 && ( + + )} + + )} +
+ + + + + +
+ ) +} + +function ResponseSection({ responses }: { responses: ResponseSectionData[] }) { + const [selectedStatus, setSelectedStatus] = useState(responses[0]?.status ?? '200') + if (responses.length === 0) return null + const active = responses.find((r) => r.status === selectedStatus) ?? responses[0] + + return ( + + {active.contentType && ( + {active.contentType} + )} + + } /> + } + > + {active.status} + + + {responses.map((resp) => ( + setSelectedStatus(resp.status)}> + {resp.status}{resp.description ? ` — ${resp.description}` : ''} + + ))} + + + + } + /> + ) +} + +function paramsToFields(params: OpenAPIV3.ParameterObject[]): SchemaField[] { + return params.map((p) => { + const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject + return { + name: p.name, + type: schema.type ? String(schema.type) : 'string', + kind: toKind(schema.type), + required: p.required ?? false, + description: p.description, + default: schema.default, + } + }) +} + +interface RequestBody { + contentType: string + fields: SchemaField[] + jsonExample: string +} + +function getRequestBody(body: OpenAPIV3.RequestBodyObject | undefined): RequestBody | null { + if (!body?.content) return null + const contentType = Object.keys(body.content)[0] + if (!contentType) return null + const schema = body.content[contentType]?.schema as OpenAPIV3.SchemaObject | undefined + if (!schema) return null + return { + contentType, + fields: flattenSchema(schema), + jsonExample: JSON.stringify(generateExampleJson(schema), null, 2), + } +} + +interface ResponseSectionData { + status: string + description?: string + contentType?: string + fields: SchemaField[] + jsonExample?: string +} + +function getResponseSections(responses: Record): ResponseSectionData[] { + return Object.entries(responses).map(([status, resp]) => { + const content = resp.content ?? {} + const contentType = Object.keys(content)[0] + const schema = contentType + ? (content[contentType]?.schema as OpenAPIV3.SchemaObject | undefined) + : undefined + + return { + status, + description: resp.description, + contentType, + fields: schema ? flattenSchema(schema) : [], + jsonExample: schema ? JSON.stringify(generateExampleJson(schema), null, 2) : undefined, + } + }) +} diff --git a/packages/chronicle/src/components/api/api-response-panel.module.css b/packages/chronicle/src/components/api/api-response-panel.module.css new file mode 100644 index 00000000..18cec0c1 --- /dev/null +++ b/packages/chronicle/src/components/api/api-response-panel.module.css @@ -0,0 +1,62 @@ +.label { + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + color: var(--rs-color-foreground-base-primary); +} + +.container { + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + overflow: hidden; + height: 440px; + width: 100%; +} + +.header { + padding: var(--rs-space-3) var(--rs-space-5); + background: var(--rs-color-background-base-secondary); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + flex-shrink: 0; +} + +.tab { + display: flex; + align-items: center; + justify-content: center; + height: 20px; + padding: 0 var(--rs-space-2); + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + background: transparent; + cursor: pointer; + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-secondary); +} + +.tab:hover { + background: var(--rs-color-background-neutral-secondary); +} + +.tabActive { + background: var(--rs-color-background-neutral-primary); + border-color: var(--rs-color-border-base-secondary); + color: var(--rs-color-foreground-base-primary); +} + +.body { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + background: var(--rs-color-background-base-primary); +} + +.codeBlock { + border: none; + border-radius: 0; +} diff --git a/packages/chronicle/src/components/api/api-response-panel.tsx b/packages/chronicle/src/components/api/api-response-panel.tsx new file mode 100644 index 00000000..1f9650e6 --- /dev/null +++ b/packages/chronicle/src/components/api/api-response-panel.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useState } from 'react' +import { CodeBlock, CopyButton, Flex } from '@raystack/apsara' +import styles from './api-response-panel.module.css' + +interface ResponseData { + status: string + description?: string + jsonExample?: string +} + +interface ApiResponsePanelProps { + responses: ResponseData[] +} + +export function ApiResponsePanel({ responses }: ApiResponsePanelProps) { + const [selected, setSelected] = useState(responses[0]?.status ?? '') + + if (responses.length === 0) return null + + const active = responses.find((r) => r.status === selected) ?? responses[0] + const displayJson = active.jsonExample ?? '{}' + + return ( + + Response: + + + + {responses.map((resp) => ( + + ))} + + + +
+ + + {displayJson} + + +
+
+
+ ) +} diff --git a/packages/chronicle/src/components/api/code-snippets.module.css b/packages/chronicle/src/components/api/code-snippets.module.css deleted file mode 100644 index 2ec0429e..00000000 --- a/packages/chronicle/src/components/api/code-snippets.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.snippets { - width: 100%; -} - -.snippets :global([class*="code-block-module_header"]) { - justify-content: space-between; -} diff --git a/packages/chronicle/src/components/api/code-snippets.tsx b/packages/chronicle/src/components/api/code-snippets.tsx deleted file mode 100644 index 84ed9daf..00000000 --- a/packages/chronicle/src/components/api/code-snippets.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import { CodeBlock } from "@raystack/apsara"; -import { - generateCurl, - generatePython, - generateGo, - generateTypeScript, -} from "@/lib/snippet-generators"; -import styles from "./code-snippets.module.css"; - -interface CodeSnippetsProps { - method: string; - url: string; - headers: Record; - body?: string; -} - -const languages = [ - { value: "curl", label: "cURL", lang: "curl", generate: generateCurl }, - { - value: "python", - label: "Python", - lang: "python", - generate: generatePython, - }, - { value: "go", label: "Go", lang: "go", generate: generateGo }, - { - value: "typescript", - label: "TypeScript", - lang: "typescript", - generate: generateTypeScript, - }, -]; - -export function CodeSnippets({ - method, - url, - headers, - body, -}: CodeSnippetsProps) { - const opts = { method, url, headers, body }; - const [selected, setSelected] = useState("curl"); - const current = languages.find((l) => l.value === selected) ?? languages[0]; - - const code = useMemo( - () => current.generate(opts), - [selected, method, url, headers, body], - ); - - return ( - - - - - - {languages.map((l) => ( - - {l.label} - - ))} - - - - - - {code} - - - ); -} diff --git a/packages/chronicle/src/components/api/endpoint-page.module.css b/packages/chronicle/src/components/api/endpoint-page.module.css deleted file mode 100644 index a28a838b..00000000 --- a/packages/chronicle/src/components/api/endpoint-page.module.css +++ /dev/null @@ -1,58 +0,0 @@ -.layout { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--rs-space-9); - padding: var(--rs-space-7); - max-width: 1400px; -} - -.left { - gap: var(--rs-space-7); - min-width: 0; -} - -.right { - gap: var(--rs-space-5); - min-width: 0; -} - -.title { - margin: 0; -} - -.description { - color: var(--rs-color-foreground-base-secondary); -} - -.methodPath { - gap: var(--rs-space-3); - padding: var(--rs-space-4) var(--rs-space-5); - border: 1px solid var(--rs-color-border-base-primary); - border-radius: 8px; - background: var(--rs-color-background-base-secondary); - overflow: hidden; -} - -.path { - font-family: monospace; - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tryButton { - margin-left: auto; - flex-shrink: 0; -} - -@media (max-width: 900px) { - .layout { - grid-template-columns: 1fr; - } - - .right { - position: static; - } -} diff --git a/packages/chronicle/src/components/api/endpoint-page.tsx b/packages/chronicle/src/components/api/endpoint-page.tsx deleted file mode 100644 index 3b13dce9..00000000 --- a/packages/chronicle/src/components/api/endpoint-page.tsx +++ /dev/null @@ -1,283 +0,0 @@ -'use client' - -import { useState, useCallback } from 'react' -import type { OpenAPIV3 } from 'openapi-types' -import { Flex, Text, Headline, Button, CodeBlock } from '@raystack/apsara' -import { MethodBadge } from './method-badge' -import { FieldSection } from './field-section' -import { KeyValueEditor, type KeyValueEntry } from './key-value-editor' -import { CodeSnippets } from './code-snippets' -import { ResponsePanel } from './response-panel' -import { flattenSchema, generateExampleJson, type SchemaField } from '@/lib/schema' -import styles from './endpoint-page.module.css' - -interface EndpointPageProps { - method: string - path: string - operation: OpenAPIV3.OperationObject - serverUrl: string - specName: string - auth?: { type: string; header: string; placeholder?: string } -} - -export function EndpointPage({ method, path, operation, serverUrl, specName, auth }: EndpointPageProps) { - const params = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[] - const body = getRequestBody(operation.requestBody as OpenAPIV3.RequestBodyObject | undefined) - - const headerFields = paramsToFields(params.filter((p) => p.in === 'header')) - const headerLocations = Object.fromEntries(headerFields.map((f) => [f.name, 'header'])) - const pathFields = paramsToFields(params.filter((p) => p.in === 'path')) - const pathLocations = Object.fromEntries(pathFields.map((f) => [f.name, 'path'])) - const queryFields = paramsToFields(params.filter((p) => p.in === 'query')) - const queryLocations = Object.fromEntries(queryFields.map((f) => [f.name, 'query'])) - const responses = getResponseSections(operation.responses as Record) - - // State for editable fields - const [customHeaders, setCustomHeaders] = useState(() => { - const initial: KeyValueEntry[] = [] - if (auth) initial.push({ key: auth.header, value: '' }) - return initial - }) - const [headerValues, setHeaderValues] = useState>({}) - const [pathValues, setPathValues] = useState>({}) - const [queryValues, setQueryValues] = useState>({}) - const [bodyValues, setBodyValues] = useState>(() => { - try { return body?.jsonExample ? JSON.parse(body.jsonExample) : {} } - catch { return {} } - }) - const [bodyJsonStr, setBodyJsonStr] = useState(body?.jsonExample ?? '{}') - const [responseBody, setResponseBody] = useState<{ status: number; statusText: string; body: unknown } | null>(null) - const [loading, setLoading] = useState(false) - - // Two-way sync: fields → JSON - const handleBodyValuesChange = useCallback((values: Record) => { - setBodyValues(values) - setBodyJsonStr(JSON.stringify(values, null, 2)) - }, []) - - // Two-way sync: JSON → fields - const handleBodyJsonChange = useCallback((jsonStr: string) => { - setBodyJsonStr(jsonStr) - try { - setBodyValues(JSON.parse(jsonStr)) - } catch { /* ignore invalid JSON while typing */ } - }, []) - - // Try it handler - const handleTryIt = useCallback(async () => { - setLoading(true) - setResponseBody(null) - - let resolvedPath = path - for (const [key, value] of Object.entries(pathValues)) { - resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value))) - } - - const queryEntries = Object.entries(queryValues).filter(([, v]) => v !== undefined && v !== '') - const queryString = queryEntries - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) - .join('&') - const fullPath = queryString ? `${resolvedPath}?${queryString}` : resolvedPath - - const reqHeaders: Record = {} - for (const [key, value] of Object.entries(headerValues)) { - if (value !== undefined && value !== null && value !== '') reqHeaders[key] = String(value) - } - for (const entry of customHeaders) { - if (entry.key && entry.value) reqHeaders[entry.key] = entry.value - } - if (body && bodyJsonStr) { - reqHeaders['Content-Type'] = body.contentType ?? 'application/json' - } - - try { - const res = await fetch('/api/apis-proxy', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - specName, - method, - path: fullPath, - headers: reqHeaders, - body: body ? bodyValues : undefined, - }), - }) - const data = await res.json() - if (data.status !== undefined) { - setResponseBody(data) - } else { - setResponseBody({ status: res.status, statusText: res.statusText, body: data.error ?? data }) - } - } catch (err) { - console.error('API request failed:', err) - setResponseBody({ status: 0, statusText: 'Error', body: 'Failed to send request' }) - } finally { - setLoading(false) - } - }, [specName, method, path, pathValues, queryValues, headerValues, customHeaders, bodyValues, bodyJsonStr, body]) - - // Snippet display values - const fullUrl = '{domain}' + path - const snippetHeaders: Record = {} - if (auth) { - snippetHeaders[auth.header] = auth.placeholder ?? 'YOUR_API_KEY' - } - if (body) { - snippetHeaders['Content-Type'] = body.contentType ?? 'application/json' - } - - return ( -
- - {operation.summary && ( - {operation.summary} - )} - {operation.description && ( - {operation.description} - )} - - - - {path} - - - - - - - - - {body && ( - - )} - - {responses.map((resp) => ( - - ))} - - - - - {responseBody && ( - - - Response — {responseBody.status} {responseBody.statusText} - - - - - - - - {typeof responseBody.body === 'string' - ? (responseBody.body || 'No response body') - : (JSON.stringify(responseBody.body, null, 2) ?? 'No response body')} - - - - - )} - -
- ) -} - -function paramsToFields(params: OpenAPIV3.ParameterObject[]): SchemaField[] { - return params.map((p) => { - const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject - return { - name: p.name, - type: schema.type ? String(schema.type) : 'string', - required: p.required ?? false, - description: p.description, - default: schema.default, - } - }) -} - -interface RequestBody { - contentType: string - fields: SchemaField[] - jsonExample: string -} - -function getRequestBody(body: OpenAPIV3.RequestBodyObject | undefined): RequestBody | null { - if (!body?.content) return null - const contentType = Object.keys(body.content)[0] - if (!contentType) return null - const schema = body.content[contentType]?.schema as OpenAPIV3.SchemaObject | undefined - if (!schema) return null - return { - contentType, - fields: flattenSchema(schema), - jsonExample: JSON.stringify(generateExampleJson(schema), null, 2), - } -} - -interface ResponseSection { - status: string - description?: string - fields: SchemaField[] - jsonExample?: string -} - -function getResponseSections(responses: Record): ResponseSection[] { - return Object.entries(responses).map(([status, resp]) => { - const content = resp.content ?? {} - const contentType = Object.keys(content)[0] - const schema = contentType - ? (content[contentType]?.schema as OpenAPIV3.SchemaObject | undefined) - : undefined - - return { - status, - description: resp.description, - fields: schema ? flattenSchema(schema) : [], - jsonExample: schema ? JSON.stringify(generateExampleJson(schema), null, 2) : undefined, - } - }) -} diff --git a/packages/chronicle/src/components/api/field-row.module.css b/packages/chronicle/src/components/api/field-row.module.css deleted file mode 100644 index 44c12344..00000000 --- a/packages/chronicle/src/components/api/field-row.module.css +++ /dev/null @@ -1,126 +0,0 @@ -.row { - display: flex; - flex-direction: column; - gap: var(--rs-space-2); - padding: var(--rs-space-4) 0; -} - -.row + .row { - border-top: 1px solid var(--rs-color-border-base-primary); -} - -.main { - gap: var(--rs-space-2); -} - -.badges { - gap: var(--rs-space-3); - flex-wrap: wrap; -} - -.name { - font-family: monospace; - font-size: 13px; - color: var(--rs-color-foreground-base-primary); -} - -.type { - font-family: monospace; - font-size: 12px; - padding: 1px var(--rs-space-2); - border-radius: 4px; - background: var(--rs-color-background-neutral-secondary); - color: var(--rs-color-foreground-base-secondary); -} - -.location { - font-size: 12px; - padding: 1px var(--rs-space-2); - border-radius: 4px; - background: var(--rs-color-background-neutral-secondary); - color: var(--rs-color-foreground-base-secondary); -} - -.required { - font-size: 11px; - padding: 1px var(--rs-space-2); - border-radius: 4px; - background: var(--rs-color-background-danger-primary); - color: var(--rs-color-foreground-danger-primary); -} - -.description { - color: var(--rs-color-foreground-base-secondary); - font-size: 13px; -} - -.example { - color: var(--rs-color-foreground-base-secondary); - font-size: 12px; -} - -.example code { - font-family: monospace; - background: var(--rs-color-background-neutral-secondary); - padding: 1px var(--rs-space-2); - border-radius: 3px; -} - -.accordion { - border: none; -} - -.accordion :global([class*="accordion-header"]) { - margin: 0; -} - -.accordion button { - min-height: unset; - padding: 0; - border: none; - background: transparent; - box-shadow: none; -} - -.accordion button:hover, -.accordion button:focus-visible { - background: transparent; -} - -.accordion :global([class*="accordion-content-inner"]) { - padding: var(--rs-space-3) 0 0 0; - border: none; - box-shadow: none; -} - -.children { - display: flex; - flex-direction: column; - padding-left: var(--rs-space-5); - width: 100%; -} - -.trigger { - color: var(--rs-color-foreground-base-secondary); -} - -.fieldInfo { - flex: 1; - min-width: 0; -} - -.fieldInput { - flex: 1; - min-width: 0; -} - -.arrayItems { - gap: var(--rs-space-3); -} - -.arrayItem { - align-items: center; - border: 1px solid var(--rs-color-border-base-primary); - border-radius: 8px; - padding: var(--rs-space-3) var(--rs-space-4); -} diff --git a/packages/chronicle/src/components/api/field-row.tsx b/packages/chronicle/src/components/api/field-row.tsx deleted file mode 100644 index fb7d1641..00000000 --- a/packages/chronicle/src/components/api/field-row.tsx +++ /dev/null @@ -1,204 +0,0 @@ -'use client' - -import { Flex, Text, Accordion, InputField, Switch, Select, IconButton } from '@raystack/apsara' -import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline' -import type { SchemaField } from '@/lib/schema' -import styles from './field-row.module.css' - -interface FieldRowProps { - field: SchemaField - location?: string - editable?: boolean - value?: unknown - onChange?: (name: string, value: unknown) => void -} - -export function FieldRow({ field, location, editable, value, onChange }: FieldRowProps) { - const hasChildren = field.children && field.children.length > 0 - const isArray = field.type.endsWith('[]') - - const label = ( - - {field.name} - {field.type} - {location && {location}} - {field.required && required} - - ) - - if (hasChildren && !isArray) { - const objValue = (value ?? {}) as Record - return ( -
- - - {label} - -
- {field.children!.map((child) => ( - { - onChange?.(field.name, { ...objValue, [name]: val }) - } : undefined} - /> - ))} -
-
-
-
-
- ) - } - - if (isArray && editable) { - const items = (Array.isArray(value) ? value : []) as unknown[] - const itemChildren = field.children - - return ( -
- - - {label} - { - const newItem = itemChildren ? {} : '' - onChange?.(field.name, [...items, newItem]) - }}> - - - - {field.description && {field.description}} - - {items.map((item, i) => ( - - {itemChildren ? ( - - {itemChildren.map((child) => ( - )?.[child.name]} - onChange={(name, val) => { - const updated = [...items] - updated[i] = { ...(updated[i] as Record), [name]: val } - onChange?.(field.name, updated) - }} - /> - ))} - - ) : ( - { - const updated = [...items] - updated[i] = val - onChange?.(field.name, updated) - }} - /> - )} - { - const updated = items.filter((_, j) => j !== i) - onChange?.(field.name, updated) - }}> - - - - ))} - - -
- ) - } - - // Leaf field — inline layout - return ( -
- - - {label} - {field.description && {field.description}} - - {editable ? ( -
- -
- ) : ( - field.default !== undefined && ( - - Default: {JSON.stringify(field.default)} - - ) - )} -
-
- ) -} - -function EditableInput({ - field, - value, - onChange, -}: { - field: SchemaField - value: unknown - onChange?: (name: string, value: unknown) => void -}) { - if (field.enum) { - const enumMap = new Map(field.enum.map((opt) => [String(opt), opt])) - return ( - - ) - } - - const baseType = field.type.replace('[]', '').replace(/\(.*\)/, '') - - if (baseType === 'boolean') { - return ( - onChange?.(field.name, checked)} - /> - ) - } - - if (baseType === 'integer' || baseType === 'number') { - return ( - onChange?.(field.name, Number(e.target.value))} - /> - ) - } - - return ( - onChange?.(field.name, e.target.value)} - /> - ) -} diff --git a/packages/chronicle/src/components/api/field-section.module.css b/packages/chronicle/src/components/api/field-section.module.css deleted file mode 100644 index 279e047b..00000000 --- a/packages/chronicle/src/components/api/field-section.module.css +++ /dev/null @@ -1,24 +0,0 @@ -.header { - padding-bottom: var(--rs-space-3); -} - -.label { - font-family: monospace; - color: var(--rs-color-foreground-base-secondary); - font-size: 13px; -} - -.separator { - height: 1px; - background: var(--rs-color-border-base-primary); -} - -.tabs { - margin-top: var(--rs-space-3); -} - -.noFields { - color: var(--rs-color-foreground-base-secondary); - padding: var(--rs-space-5); -} - diff --git a/packages/chronicle/src/components/api/field-section.tsx b/packages/chronicle/src/components/api/field-section.tsx deleted file mode 100644 index 7c484c07..00000000 --- a/packages/chronicle/src/components/api/field-section.tsx +++ /dev/null @@ -1,100 +0,0 @@ -'use client' - -import { Flex, Text, Tabs, CodeBlock } from '@raystack/apsara' -import type { SchemaField } from '@/lib/schema' -import { FieldRow } from './field-row' -import { JsonEditor } from './json-editor' -import styles from './field-section.module.css' - -interface FieldSectionProps { - title: string - label?: string - fields: SchemaField[] - locations?: Record - jsonExample?: string - editableJson?: boolean - onJsonChange?: (value: string) => void - alwaysShow?: boolean - editable?: boolean - values?: Record - onValuesChange?: (values: Record) => void - children?: React.ReactNode -} - -export function FieldSection({ - title, label, fields, locations, jsonExample, - editableJson, onJsonChange, alwaysShow, - editable, values, onValuesChange, children, -}: FieldSectionProps) { - if (fields.length === 0 && !children && !alwaysShow) return null - - const fieldsContent = fields.length > 0 ? ( - - {fields.map((field) => ( - { - onValuesChange?.({ ...values, [name]: val }) - } : undefined} - /> - ))} - - ) : !children ? ( - No fields defined - ) : null - - if (jsonExample !== undefined || alwaysShow) { - return ( - - - {title} - {label && {label}} - -
- - - Fields - JSON - - - {fieldsContent} - {children} - - - {editableJson ? ( - - ) : ( - - - - - - {jsonExample ?? '{}'} - - - )} - - - - ) - } - - return ( - - - {title} - {label && {label}} - -
- {fieldsContent} - {children} - - ) -} diff --git a/packages/chronicle/src/components/api/index.ts b/packages/chronicle/src/components/api/index.ts index 26711154..5550710a 100644 --- a/packages/chronicle/src/components/api/index.ts +++ b/packages/chronicle/src/components/api/index.ts @@ -1,8 +1,7 @@ -export { EndpointPage } from './endpoint-page' +export { ApiOverview } from './api-overview' +export { ApiFieldSection } from './api-field-list' +export { ApiCodeSnippet } from './api-code-snippet' +export { ApiResponsePanel } from './api-response-panel' +export { PlaygroundDialog } from './playground-dialog' export { MethodBadge } from './method-badge' -export { FieldSection } from './field-section' -export { FieldRow } from './field-row' -export { CodeSnippets } from './code-snippets' -export { ResponsePanel } from './response-panel' export { JsonEditor } from './json-editor' -export { KeyValueEditor } from './key-value-editor' diff --git a/packages/chronicle/src/components/api/json-editor.tsx b/packages/chronicle/src/components/api/json-editor.tsx index 730e9028..fb0e2afb 100644 --- a/packages/chronicle/src/components/api/json-editor.tsx +++ b/packages/chronicle/src/components/api/json-editor.tsx @@ -17,9 +17,11 @@ interface JsonEditorProps { export function JsonEditor({ value, onChange, readOnly }: JsonEditorProps) { const containerRef = useRef(null) const viewRef = useRef(null) + const onChangeRef = useRef(onChange) const { theme } = useTheme() const isDark = theme === 'dark' + onChangeRef.current = onChange useEffect(() => { if (!containerRef.current) return @@ -30,13 +32,11 @@ export function JsonEditor({ value, onChange, readOnly }: JsonEditorProps) { EditorView.lineWrapping, ...(isDark ? [oneDark] : []), ...(readOnly ? [EditorState.readOnly.of(true)] : []), - ...(onChange - ? [EditorView.updateListener.of((update) => { - if (update.docChanged) { - onChange(update.state.doc.toString()) - } - })] - : []), + EditorView.updateListener.of((update) => { + if (update.docChanged && onChangeRef.current) { + onChangeRef.current(update.state.doc.toString()) + } + }), ] const state = EditorState.create({ doc: value, extensions }) @@ -44,7 +44,7 @@ export function JsonEditor({ value, onChange, readOnly }: JsonEditorProps) { viewRef.current = view return () => view.destroy() - }, [isDark, readOnly, onChange]) + }, [isDark, readOnly]) useEffect(() => { const view = viewRef.current diff --git a/packages/chronicle/src/components/api/key-value-editor.module.css b/packages/chronicle/src/components/api/key-value-editor.module.css deleted file mode 100644 index a972cdce..00000000 --- a/packages/chronicle/src/components/api/key-value-editor.module.css +++ /dev/null @@ -1,13 +0,0 @@ -.editor { - padding: var(--rs-space-3) 0; -} - -.row { - width: 100%; -} - -.input { - flex: 1; - min-width: 0; -} - diff --git a/packages/chronicle/src/components/api/key-value-editor.tsx b/packages/chronicle/src/components/api/key-value-editor.tsx deleted file mode 100644 index ed8ce391..00000000 --- a/packages/chronicle/src/components/api/key-value-editor.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client' - -import { Flex, InputField, IconButton, Button } from '@raystack/apsara' -import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline' -import styles from './key-value-editor.module.css' - -export interface KeyValueEntry { - key: string - value: string -} - -interface KeyValueEditorProps { - entries: KeyValueEntry[] - onChange: (entries: KeyValueEntry[]) => void -} - -export function KeyValueEditor({ entries, onChange }: KeyValueEditorProps) { - const updateEntry = (index: number, field: 'key' | 'value', val: string) => { - const updated = [...entries] - updated[index] = { ...updated[index], [field]: val } - onChange(updated) - } - - const removeEntry = (index: number) => { - onChange(entries.filter((_, i) => i !== index)) - } - - const addEntry = () => { - onChange([...entries, { key: '', value: '' }]) - } - - return ( - - {entries.map((entry, i) => ( - -
- updateEntry(i, 'key', e.target.value)} - /> -
-
- updateEntry(i, 'value', e.target.value)} - /> -
- removeEntry(i)}> - - -
- ))} - -
- ) -} diff --git a/packages/chronicle/src/components/api/method-badge.tsx b/packages/chronicle/src/components/api/method-badge.tsx index 278f3e7b..8f231ac2 100644 --- a/packages/chronicle/src/components/api/method-badge.tsx +++ b/packages/chronicle/src/components/api/method-badge.tsx @@ -6,8 +6,8 @@ import styles from './method-badge.module.css' type BadgeVariant = 'accent' | 'danger' | 'success' | 'neutral' | 'warning' | 'gradient' const methodVariants: Record = { - GET: 'accent', - POST: 'success', + GET: 'success', + POST: 'accent', PUT: 'warning', DELETE: 'danger', PATCH: 'neutral', diff --git a/packages/chronicle/src/components/api/playground-dialog.module.css b/packages/chronicle/src/components/api/playground-dialog.module.css new file mode 100644 index 00000000..9e78160d --- /dev/null +++ b/packages/chronicle/src/components/api/playground-dialog.module.css @@ -0,0 +1,342 @@ +.dialog { + padding: 0 !important; + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + overflow: hidden; + height: 70vh; + max-height: 600px; + width: 70vw; + max-width: 900px; + display: flex; + flex-direction: column; +} + +.actionNav { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--rs-space-3) var(--rs-space-5); + background: var(--rs-color-background-base-secondary); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + flex-shrink: 0; + z-index: 3; +} + +.actionNavTitle { + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + color: var(--rs-color-foreground-base-primary); +} + +.splitPanel { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + z-index: 2; +} + +.leftPanel { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.rightPanel { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + border-left: 0.5px solid var(--rs-color-border-base-primary); + overflow: hidden; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--rs-space-5); + height: 42px; + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + flex-shrink: 0; +} + +.panelTitle { + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + color: var(--rs-color-foreground-base-primary); + flex: 1; +} + +.tabBar { + display: flex; + align-items: center; + gap: var(--rs-space-6); + padding: var(--rs-space-2) var(--rs-space-5) 0; + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + background: var(--rs-color-background-base-primary); + flex-shrink: 0; +} + +.tab { + display: flex; + align-items: center; + justify-content: center; + height: 24px; + padding: 0; + border: none; + border-bottom: 1px solid transparent; + background: transparent; + cursor: pointer; + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + color: var(--rs-color-foreground-base-secondary); +} + +.tab:hover { + color: var(--rs-color-foreground-base-primary); +} + +.tabActive { + color: var(--rs-color-foreground-base-primary); + border-bottom-color: var(--rs-color-border-base-emphasis); +} + +.fieldsScroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-bottom: var(--rs-space-5); +} + +.sectionHeader { + display: flex; + align-items: center; + gap: var(--rs-space-3); + padding: var(--rs-space-3) var(--rs-space-5); + background: var(--rs-color-background-base-secondary); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + border-top: 0.5px solid var(--rs-color-border-base-primary); + flex-shrink: 0; +} + +.sectionLabel { + flex: 1; + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); +} + +.fieldRow { + display: flex; + align-items: center; + gap: 10px; + padding: var(--rs-space-4) var(--rs-space-5); +} + +.fieldLabel { + flex: 1; + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); + min-width: 0; +} + +.fieldInput { + width: 50%; + flex-shrink: 0; +} + +.fieldInput input { + height: 24px; + font-size: var(--rs-font-size-small); +} + +.arrayField { + display: flex; + flex-direction: column; +} + +.nestedFields { + padding-left: var(--rs-space-5); + border-left: 1px dashed var(--rs-color-border-base-primary); + margin-left: var(--rs-space-5); +} + +.arrayItemRow { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--rs-space-3); + padding: var(--rs-space-2) var(--rs-space-5) var(--rs-space-2) var(--rs-space-8); +} + +.jsonEditorWrap { + flex: 1; + min-height: 150px; + border-bottom: 0.5px solid var(--rs-color-border-base-primary); +} + +.responseHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--rs-space-5); + height: 42px; + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + flex-shrink: 0; +} + +.statusBar { + display: flex; + align-items: center; + gap: var(--rs-space-3); + padding: var(--rs-space-3) var(--rs-space-5); + background: var(--rs-color-background-base-secondary); + flex-shrink: 0; +} + +.statusText { + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); +} + +.statusValue { + color: var(--rs-color-foreground-success-primary); +} + +.statusSeparator { + width: 1px; + height: 12px; + background: var(--rs-color-border-base-tertiary); + border-radius: 2px; +} + +.responseBody { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: var(--rs-space-5); +} + +.responseCode { + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-mono-small); + line-height: 18px; + color: var(--rs-color-foreground-danger-primary); + white-space: pre-wrap; + word-break: break-all; + margin: 0; +} + +.lineNumbers { + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-mono-small); + line-height: 18px; + color: var(--rs-color-foreground-base-tertiary); + opacity: 0.5; + text-align: right; + white-space: nowrap; + user-select: none; + flex-shrink: 0; +} + +.codeArea { + display: flex; + gap: var(--rs-space-5); + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: var(--rs-space-5); +} + +.headersArea { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--rs-space-3) 0; +} + +.headerRow { + display: flex; + align-items: baseline; + gap: var(--rs-space-3); + padding: var(--rs-space-2) var(--rs-space-5); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); +} + +.headerKey { + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-mono-small); + line-height: 18px; + color: var(--rs-color-foreground-base-primary); + font-weight: var(--rs-font-weight-medium); + white-space: nowrap; +} + +.headerValue { + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-mono-small); + line-height: 18px; + color: var(--rs-color-foreground-base-secondary); + word-break: break-all; +} + +.emptyResponse { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--rs-color-foreground-base-tertiary); + font-size: var(--rs-font-size-small); +} + +.bottomBar { + display: flex; + align-items: center; + gap: var(--rs-space-6); + padding: var(--rs-space-3) var(--rs-space-5); + background: var(--rs-color-background-base-primary); + border-top: 0.5px solid var(--rs-color-border-base-primary); + flex-shrink: 0; + z-index: 1; +} + +.pathBar { + display: flex; + align-items: center; + justify-content: space-between; + flex: 1; + min-width: 0; + padding: var(--rs-space-2); + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + background: var(--rs-color-background-base-primary); + gap: 8px; +} + +.pathText { + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-mono-small); + line-height: var(--rs-line-height-small); + color: var(--rs-color-foreground-base-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/packages/chronicle/src/components/api/playground-dialog.tsx b/packages/chronicle/src/components/api/playground-dialog.tsx new file mode 100644 index 00000000..4bd1cfef --- /dev/null +++ b/packages/chronicle/src/components/api/playground-dialog.tsx @@ -0,0 +1,583 @@ +'use client' + +import { useState, useCallback, useMemo } from 'react' +import type { OpenAPIV3 } from 'openapi-types' +import { Dialog, Button, Badge, IconButton, InputField, CopyButton, Select, Menu } from '@raystack/apsara' +import { Cross2Icon, ChevronDownIcon, ChevronUpIcon, PlayIcon, PlusIcon } from '@radix-ui/react-icons' +import { CounterClockwiseClockIcon, CodeIcon } from '@radix-ui/react-icons' +import { MethodBadge } from '@/components/api/method-badge' +import { flattenSchema, generateExampleJson, toKind, type SchemaField } from '@/lib/schema' +import { generateCurl } from '@/lib/snippet-generators' +import { JsonEditor } from '@/components/api/json-editor' +import styles from './playground-dialog.module.css' + +type AuthScheme = { + name: string + type: 'apiKey' | 'bearer' | 'basic' | 'none' + headerName: string + placeholder: string +} + +function getAuthSchemes( + document: OpenAPIV3.Document, + auth?: { type: string; header: string; placeholder?: string } +): AuthScheme[] { + const schemes: AuthScheme[] = [{ name: 'None', type: 'none', headerName: '', placeholder: '' }] + const securitySchemes = (document.components?.securitySchemes ?? {}) as Record + + for (const [name, scheme] of Object.entries(securitySchemes)) { + if (scheme.type === 'apiKey' && 'name' in scheme && 'in' in scheme && scheme.in === 'header') { + schemes.push({ name: `API Key (${scheme.name ?? name})`, type: 'apiKey', headerName: scheme.name ?? name, placeholder: 'Enter API key' }) + } else if (scheme.type === 'http' && 'scheme' in scheme) { + if (scheme.scheme === 'bearer') { + schemes.push({ name: 'Bearer Token', type: 'bearer', headerName: 'Authorization', placeholder: 'Enter bearer token' }) + } else if (scheme.scheme === 'basic') { + schemes.push({ name: 'Basic Auth', type: 'basic', headerName: 'Authorization', placeholder: '' }) + } + } + } + + if (auth && !schemes.some((s) => s.headerName === auth.header && s.type !== 'none')) { + schemes.push({ name: `API Key (${auth.header})`, type: 'apiKey', headerName: auth.header, placeholder: auth.placeholder ?? 'Enter API key' }) + } + + return schemes +} + +interface PlaygroundDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + method: string + path: string + operation: OpenAPIV3.OperationObject + serverUrl: string + specName: string + auth?: { type: string; header: string; placeholder?: string } + document: OpenAPIV3.Document +} + +export function PlaygroundDialog({ + open, onOpenChange, method, path, operation, serverUrl, specName, auth, document, +}: PlaygroundDialogProps) { + const params = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[] + const body = getRequestBody(operation.requestBody as OpenAPIV3.RequestBodyObject | undefined) + + const headerFields = paramsToFields(params.filter((p) => p.in === 'header')) + const pathFields = paramsToFields(params.filter((p) => p.in === 'path')) + const queryFields = paramsToFields(params.filter((p) => p.in === 'query')) + + const authSchemes = useMemo(() => getAuthSchemes(document, auth), [document, auth]) + const defaultScheme = authSchemes.find((s) => s.type !== 'none') ?? authSchemes[0] + + const [selectedScheme, setSelectedScheme] = useState(defaultScheme.name) + const [authToken, setAuthToken] = useState('') + const [basicUser, setBasicUser] = useState('') + const [basicPass, setBasicPass] = useState('') + const [headerValues, setHeaderValues] = useState>({}) + const [pathValues, setPathValues] = useState>({}) + const [queryValues, setQueryValues] = useState>({}) + const [jsonMode, setJsonMode] = useState(false) + const [bodyValues, setBodyValues] = useState>(() => { + if (!body) return {} + const init: Record = {} + for (const f of body.fields) { + if (f.kind === 'array') init[f.name] = [] + else if (f.kind === 'object' || f.children) init[f.name] = {} + else init[f.name] = '' + } + return init + }) + const [bodyJsonStr, setBodyJsonStr] = useState(() => body ? body.jsonExample : '{}') + + const [responseData, setResponseData] = useState<{ + status: number; statusText: string; body: unknown; headers?: Record; time: number + } | null>(null) + const [responseView, setResponseView] = useState<'body' | 'headers'>('body') + const [loading, setLoading] = useState(false) + const [collapsed, setCollapsed] = useState>({}) + + const toggleCollapse = (section: string) => { + setCollapsed((prev) => ({ ...prev, [section]: !prev[section] })) + } + + const currentScheme = authSchemes.find((s) => s.name === selectedScheme) ?? authSchemes[0] + + const getAuthHeaders = useCallback((): Record => { + const headers: Record = {} + if (currentScheme.type === 'apiKey' && authToken) { + headers[currentScheme.headerName] = authToken + } else if (currentScheme.type === 'bearer' && authToken) { + headers['Authorization'] = `Bearer ${authToken}` + } else if (currentScheme.type === 'basic' && (basicUser || basicPass)) { + headers['Authorization'] = `Basic ${btoa(`${basicUser}:${basicPass}`)}` + } + return headers + }, [currentScheme, authToken, basicUser, basicPass]) + + const handleReset = () => { + setSelectedScheme(defaultScheme.name) + setAuthToken('') + setBasicUser('') + setBasicPass('') + setHeaderValues({}) + setPathValues({}) + setQueryValues({}) + setBodyValues(() => { + if (!body) return {} + const init: Record = {} + for (const f of body.fields) { + if (f.kind === 'array') init[f.name] = [] + else if (f.kind === 'object' || f.children) init[f.name] = {} + else init[f.name] = '' + } + return init + }) + setResponseData(null) + } + + const handleSend = useCallback(async () => { + setLoading(true) + setResponseData(null) + const startTime = performance.now() + + let resolvedPath = path + for (const [key, value] of Object.entries(pathValues)) { + if (value) resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(value)) + } + + const queryEntries = Object.entries(queryValues).filter(([, v]) => v) + const queryString = queryEntries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') + const fullPath = queryString ? `${resolvedPath}?${queryString}` : resolvedPath + + const reqHeaders: Record = { ...getAuthHeaders() } + for (const [key, value] of Object.entries(headerValues)) { + if (value) reqHeaders[key] = value + } + + let reqBody: unknown = undefined + if (body) { + reqHeaders['Content-Type'] = body.contentType ?? 'application/json' + if (jsonMode) { + try { + reqBody = JSON.parse(bodyJsonStr) + } catch { + setResponseData({ status: 0, statusText: 'Error', body: 'Invalid JSON in request body', time: 0 }) + setLoading(false) + return + } + } else { + reqBody = bodyValues + } + } + + try { + const res = await fetch('/api/apis-proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ specName, method, path: fullPath, headers: reqHeaders, body: reqBody }), + }) + const data = await res.json() + const elapsed = Math.round(performance.now() - startTime) + if (data.status !== undefined) { + setResponseData({ ...data, time: elapsed }) + } else { + setResponseData({ status: res.status, statusText: res.statusText, body: data.error ?? data, time: elapsed }) + } + } catch { + setResponseData({ status: 0, statusText: 'Error', body: 'Failed to send request', time: 0 }) + } finally { + setLoading(false) + } + }, [specName, method, path, pathValues, queryValues, getAuthHeaders, headerValues, bodyValues, body]) + + const responseJson = responseData + ? (typeof responseData.body === 'string' ? responseData.body : JSON.stringify(responseData.body, null, 2)) + : '' + + const responseLines = responseJson ? responseJson.split('\n') : [] + + const curlSnippet = useMemo(() => { + const headers: Record = { ...getAuthHeaders(), ...headerValues } + if (body) headers['Content-Type'] = body.contentType ?? 'application/json' + const bodyStr = body ? (jsonMode ? bodyJsonStr : JSON.stringify(bodyValues)) : undefined + return generateCurl({ method, url: serverUrl + path, headers, body: bodyStr }) + }, [method, path, serverUrl, getAuthHeaders, headerValues, bodyValues, bodyJsonStr, jsonMode, body]) + + + return ( + + + {/* Action Nav */} +
+ {operation.summary ?? `${method} ${path}`} +
+ + + + onOpenChange(false)} aria-label="Close"> + + +
+
+ + {/* Split Panel */} +
+ {/* Left Panel */} +
+
+ Test request +
+ + {/* Fields */} +
+ {/* Auth Section */} + {( + <> +
+ Authorization + toggleCollapse('auth')}> + {collapsed.auth ? : } + +
+ {!collapsed.auth && ( + <> + {authSchemes.length > 2 && ( +
+ Type +
+ +
+
+ )} + {currentScheme.type === 'basic' ? ( + <> +
+ Username +
+ setBasicUser(e.target.value)} /> +
+
+
+ Password +
+ setBasicPass(e.target.value)} /> +
+
+ + ) : currentScheme.type !== 'none' ? ( +
+ {currentScheme.headerName} +
+ setAuthToken(e.target.value)} /> +
+
+ ) : null} + {headerFields.filter((f) => f.name !== currentScheme.headerName).map((f) => ( +
+ {f.name} +
+ setHeaderValues({ ...headerValues, [f.name]: e.target.value })} /> +
+
+ ))} + + )} + + )} + + {/* Path Params */} + {pathFields.length > 0 && ( + <> +
+ Path Parameters + toggleCollapse('path')}> + {collapsed.path ? : } + +
+ {!collapsed.path && pathFields.map((f) => ( +
+ {f.name} +
+ setPathValues({ ...pathValues, [f.name]: e.target.value })} + /> +
+
+ ))} + + )} + + {/* Query Params */} + {queryFields.length > 0 && ( + <> +
+ Query Parameters + toggleCollapse('query')}> + {collapsed.query ? : } + +
+ {!collapsed.query && queryFields.map((f) => ( +
+ {f.name} +
+ setQueryValues({ ...queryValues, [f.name]: e.target.value })} + /> +
+
+ ))} + + )} + + {/* Body Section */} + {body && ( + <> +
+ Body +
+ { + if (!jsonMode) { + setBodyJsonStr(JSON.stringify(bodyValues, null, 2)) + } else { + try { setBodyValues(JSON.parse(bodyJsonStr)) } catch { /* ignore */ } + } + setJsonMode(!jsonMode) + }}> + + + toggleCollapse('body')}> + {collapsed.body ? : } + +
+
+ {!collapsed.body && ( + jsonMode ? ( +
+ setBodyJsonStr(val)} + /> +
+ ) : ( + body.fields.map((f) => ( + setBodyValues({ ...bodyValues, [f.name]: val })} + /> + )) + ) + )} + + )} +
+
+ + {/* Right Panel */} +
+
+ Response + {responseData && ( + + } />}> + {responseView === 'body' ? 'Body' : 'Headers'} + + + setResponseView('body')}>Body + setResponseView('headers')}>Headers + + + )} +
+ + {responseData ? ( + <> +
+ + Status : {responseData.status} + +
+ + Time : {responseData.time} ms + +
+ {responseView === 'body' ? ( +
+
+ {responseLines.map((_, i) => ( +
{i + 1}
+ ))} +
+
{responseJson}
+
+ ) : ( +
+ {responseData.headers ? ( + Object.entries(responseData.headers).map(([k, v]) => ( +
+ {k} + {v} +
+ )) + ) : ( +
No headers available
+ )} +
+ )} + + ) : ( +
+ {loading ? 'Sending...' : 'Send a request to see the response'} +
+ )} +
+
+ + {/* Bottom Bar */} +
+
+
+ + {path} +
+ +
+ +
+ +
+ ) +} + +function BodyFieldRow({ field, value, onChange }: { + field: SchemaField + value: unknown + onChange: (val: unknown) => void +}) { + const hasChildren = field.children && field.children.length > 0 + + if (field.kind === 'array' && !hasChildren) { + const items = Array.isArray(value) ? value as string[] : [] + return ( +
+
+ {field.name} + onChange([...items, ''])} aria-label="Add item"> + + +
+ {items.map((item, i) => ( +
+
+ { + const updated = [...items] + updated[i] = e.target.value + onChange(updated) + }} + /> +
+ onChange(items.filter((_, j) => j !== i))} aria-label="Remove item"> + + +
+ ))} +
+ ) + } + + if (hasChildren) { + const objValue = (typeof value === 'object' && value !== null ? value : {}) as Record + return ( +
+
+ {field.name} +
+
+ {field.children!.map((child) => ( + onChange({ ...objValue, [child.name]: val })} + /> + ))} +
+
+ ) + } + + return ( +
+ {field.name} +
+ onChange(e.target.value)} + /> +
+
+ ) +} + +function paramsToFields(params: OpenAPIV3.ParameterObject[]): SchemaField[] { + return params.map((p) => { + const schema = (p.schema ?? {}) as OpenAPIV3.SchemaObject + return { + name: p.name, + type: schema.type ? String(schema.type) : 'string', + kind: toKind(schema.type), + required: p.required ?? false, + description: p.description, + } + }) +} + +interface RequestBody { + contentType: string + fields: SchemaField[] + jsonExample: string +} + +function getRequestBody(body: OpenAPIV3.RequestBodyObject | undefined): RequestBody | null { + if (!body?.content) return null + const contentType = Object.keys(body.content)[0] + if (!contentType) return null + const schema = body.content[contentType]?.schema as OpenAPIV3.SchemaObject | undefined + if (!schema) return null + return { + contentType, + fields: flattenSchema(schema), + jsonExample: JSON.stringify(generateExampleJson(schema), null, 2), + } +} diff --git a/packages/chronicle/src/components/api/response-panel.module.css b/packages/chronicle/src/components/api/response-panel.module.css deleted file mode 100644 index 55f1b581..00000000 --- a/packages/chronicle/src/components/api/response-panel.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.panel { - width: 100%; -} - -/* stylelint-disable-next-line selector-pseudo-class-no-unknown */ -.panel :global([class*="code-block-module_header"]) { - justify-content: space-between; -} diff --git a/packages/chronicle/src/components/api/response-panel.tsx b/packages/chronicle/src/components/api/response-panel.tsx deleted file mode 100644 index f1b9e05d..00000000 --- a/packages/chronicle/src/components/api/response-panel.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import { CodeBlock } from '@raystack/apsara' -import styles from './response-panel.module.css' - -interface ResponsePanelProps { - responses: { - status: string - description?: string - jsonExample?: string - }[] -} - -export function ResponsePanel({ responses }: ResponsePanelProps) { - const withExamples = responses.filter((r) => r.jsonExample) - if (withExamples.length === 0) return null - - const defaultValue = withExamples[0].status - - return ( - - - - - - {withExamples.map((resp) => ( - - {resp.status} {resp.description ?? resp.status} - - ))} - - - - - - {withExamples.map((resp) => ( - - {resp.jsonExample!} - - ))} - - - ) -} diff --git a/packages/chronicle/src/lib/api-routes.ts b/packages/chronicle/src/lib/api-routes.ts index ce30fc04..bed43141 100644 --- a/packages/chronicle/src/lib/api-routes.ts +++ b/packages/chronicle/src/lib/api-routes.ts @@ -7,6 +7,15 @@ export function getSpecSlug(spec: ApiSpec): string { return slugify(spec.name, { lower: true, strict: true }) } +function deriveOperationId(method: string, path: string): string { + const slug = path.replace(/[/{}\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '') + return `${method}_${slug || 'root'}` +} + +function getOperationId(op: OpenAPIV3.OperationObject, method: string, path: string): string { + return op.operationId || deriveOperationId(method, path) +} + export function buildApiRoutes(specs: ApiSpec[]): { slug: string[] }[] { const routes: { slug: string[] }[] = [] @@ -15,12 +24,13 @@ export function buildApiRoutes(specs: ApiSpec[]): { slug: string[] }[] { const specSlug = getSpecSlug(spec) const paths = spec.document.paths ?? {} - for (const [, pathItem] of Object.entries(paths)) { + for (const [pathStr, pathItem] of Object.entries(paths)) { if (!pathItem) continue for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { const op = pathItem[method] - if (!op?.operationId) continue - routes.push({ slug: [specSlug, encodeURIComponent(op.operationId)] }) + if (!op) continue + const opId = getOperationId(op, method, pathStr) + routes.push({ slug: [specSlug, encodeURIComponent(opId)] }) } } } @@ -47,7 +57,9 @@ export function findApiOperation(specs: ApiSpec[], slug: string[]): ApiRouteMatc if (!pathItem) continue for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { const op = pathItem[method] - if (op?.operationId && encodeURIComponent(op.operationId) === operationId) { + if (!op) continue + const opId = getOperationId(op, method, pathStr) + if (encodeURIComponent(opId) === operationId) { return { spec, operation: op, method: method.toUpperCase(), path: pathStr } } } @@ -67,12 +79,13 @@ export function buildApiPageTree(specs: ApiSpec[]): Root { const opsByTag = new Map() const tagDisplayName = new Map() - for (const [, pathItem] of Object.entries(paths)) { + for (const [pathStr, pathItem] of Object.entries(paths)) { if (!pathItem) continue for (const method of ['get', 'post', 'put', 'delete', 'patch'] as const) { const op = pathItem[method] - if (!op?.operationId) continue + if (!op) continue + const opId = getOperationId(op, method, pathStr) const rawTag = op.tags?.[0] ?? 'default' const tagKey = rawTag.toLowerCase() if (!opsByTag.has(tagKey)) { @@ -82,8 +95,8 @@ export function buildApiPageTree(specs: ApiSpec[]): Root { opsByTag.get(tagKey)!.push({ type: 'page', - name: op.summary ?? op.operationId, - url: `/apis/${specSlug}/${encodeURIComponent(op.operationId)}`, + name: op.summary ?? opId, + url: `/apis/${specSlug}/${encodeURIComponent(opId)}`, icon: `method-${method}`, }) } diff --git a/packages/chronicle/src/lib/openapi.ts b/packages/chronicle/src/lib/openapi.ts index 0d8a85c6..ef9183db 100644 --- a/packages/chronicle/src/lib/openapi.ts +++ b/packages/chronicle/src/lib/openapi.ts @@ -120,12 +120,38 @@ function convertV2toV3(doc: OpenAPIV2.Document): OpenAPIV3.Document { v3Paths[pathStr] = v3PathItem } + const securitySchemes = convertV2SecurityDefs(resolved.securityDefinitions as Record | undefined) + return { openapi: '3.0.0', info: resolved.info as unknown as OpenAPIV3.InfoObject, paths: v3Paths, tags: (resolved.tags ?? []) as unknown as OpenAPIV3.TagObject[], + ...(resolved.externalDocs ? { externalDocs: resolved.externalDocs as unknown as OpenAPIV3.ExternalDocumentationObject } : {}), + ...(Object.keys(securitySchemes).length > 0 ? { components: { securitySchemes } } : {}), + } +} + +function convertV2SecurityDefs(defs: Record | undefined): Record { + if (!defs) return {} + const result: Record = {} + for (const [name, def] of Object.entries(defs)) { + if (def.type === 'apiKey') { + result[name] = { type: 'apiKey', name: (def as JsonObject).name as string, in: def.in as string } as OpenAPIV3.ApiKeySecurityScheme + } else if (def.type === 'basic') { + result[name] = { type: 'http', scheme: 'basic' } as OpenAPIV3.HttpSecurityScheme + } else if (def.type === 'oauth2') { + const v2 = def as unknown as { flow?: string; authorizationUrl?: string; tokenUrl?: string; scopes?: Record } + const flow = { authorizationUrl: v2.authorizationUrl ?? '', tokenUrl: v2.tokenUrl ?? '', scopes: v2.scopes ?? {} } + const flows: OpenAPIV3.OAuth2SecurityScheme['flows'] = {} + if (v2.flow === 'implicit') flows.implicit = { authorizationUrl: flow.authorizationUrl, scopes: flow.scopes } + else if (v2.flow === 'password') flows.password = { tokenUrl: flow.tokenUrl, scopes: flow.scopes } + else if (v2.flow === 'application') flows.clientCredentials = { tokenUrl: flow.tokenUrl, scopes: flow.scopes } + else if (v2.flow === 'accessCode') flows.authorizationCode = { authorizationUrl: flow.authorizationUrl, tokenUrl: flow.tokenUrl, scopes: flow.scopes } + result[name] = { type: 'oauth2', flows } as OpenAPIV3.OAuth2SecurityScheme + } } + return result } function convertV2Operation(op: OpenAPIV2.OperationObject): OpenAPIV3.OperationObject { diff --git a/packages/chronicle/src/lib/schema.ts b/packages/chronicle/src/lib/schema.ts index ff846161..1c21c87d 100644 --- a/packages/chronicle/src/lib/schema.ts +++ b/packages/chronicle/src/lib/schema.ts @@ -1,8 +1,21 @@ import type { OpenAPIV3 } from 'openapi-types' +const schemaFieldKinds = { + string: 'string', integer: 'integer', number: 'number', + boolean: 'boolean', array: 'array', object: 'object', +} as const + +export type SchemaFieldKind = keyof typeof schemaFieldKinds + +export function toKind(type: unknown): SchemaFieldKind { + if (typeof type === 'string' && type in schemaFieldKinds) return type as SchemaFieldKind + return 'object' +} + export interface SchemaField { name: string type: string + kind: SchemaFieldKind required: boolean description?: string default?: unknown @@ -10,10 +23,33 @@ export interface SchemaField { children?: SchemaField[] } +function mergeAllOf(schema: OpenAPIV3.SchemaObject): OpenAPIV3.SchemaObject { + const composed = schema.allOf ?? schema.oneOf ?? schema.anyOf + if (!composed) return schema + const merged: OpenAPIV3.SchemaObject = { ...schema } + delete merged.allOf + delete merged.oneOf + delete merged.anyOf + for (const sub of composed as OpenAPIV3.SchemaObject[]) { + if (sub.type) merged.type = sub.type + if (sub.properties) { + merged.properties = { ...(merged.properties ?? {}), ...sub.properties } + } + if (sub.required) { + merged.required = [...(merged.required ?? []), ...sub.required] + } + if (sub.description && !merged.description) merged.description = sub.description + } + return merged +} + export function flattenSchema( schema: OpenAPIV3.SchemaObject, requiredFields: string[] = [], ): SchemaField[] { + const resolved = mergeAllOf(schema) + if (resolved !== schema) return flattenSchema(resolved, requiredFields) + if (schema.type === 'array' && schema.items) { const items = schema.items as OpenAPIV3.SchemaObject const itemType = inferType(items) @@ -26,6 +62,7 @@ export function flattenSchema( return [{ name: 'items', type: `${itemType}[]`, + kind: 'array' as SchemaFieldKind, required: true, description: items.description, children: children?.length ? children : undefined, @@ -36,7 +73,8 @@ export function flattenSchema( const properties = (schema.properties ?? {}) as Record const required = schema.required ?? requiredFields - return Object.entries(properties).map(([name, prop]) => { + return Object.entries(properties).map(([name, rawProp]) => { + const prop = mergeAllOf(rawProp) const fieldType = inferType(prop) const children = fieldType === 'object' || prop.properties @@ -48,8 +86,9 @@ export function flattenSchema( return { name, type: fieldType, + kind: toKind(prop.type), required: required.includes(name), - description: prop.description, + description: rawProp.description ?? prop.description, default: prop.default, enum: prop.enum, children: children?.length ? children : undefined, @@ -87,7 +126,8 @@ export function generateExampleJson(schema: OpenAPIV3.SchemaObject): unknown { return defaults[schema.type as string] ?? null } -function inferType(schema: OpenAPIV3.SchemaObject): string { +function inferType(rawSchema: OpenAPIV3.SchemaObject): string { + const schema = mergeAllOf(rawSchema) if (schema.type === 'array') { const items = schema.items as OpenAPIV3.SchemaObject | undefined const itemType = items ? inferType(items) : 'unknown' diff --git a/packages/chronicle/src/lib/use-api-operation.ts b/packages/chronicle/src/lib/use-api-operation.ts new file mode 100644 index 00000000..950def62 --- /dev/null +++ b/packages/chronicle/src/lib/use-api-operation.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react' +import { useLocation } from 'react-router' +import { findApiOperation, type ApiRouteMatch } from '@/lib/api-routes' +import { usePageContext } from '@/lib/page-context' + +export function useApiOperation(): ApiRouteMatch | null { + const { apiSpecs } = usePageContext() + const { pathname } = useLocation() + + return useMemo(() => { + const slug = pathname.replace(/^\/apis\//, '').split('/').filter(Boolean) + if (slug.length !== 2) return null + return findApiOperation(apiSpecs, slug) + }, [apiSpecs, pathname]) +} diff --git a/packages/chronicle/src/pages/ApiLayout.module.css b/packages/chronicle/src/pages/ApiLayout.module.css index a3c8ffe0..4943509f 100644 --- a/packages/chronicle/src/pages/ApiLayout.module.css +++ b/packages/chronicle/src/pages/ApiLayout.module.css @@ -14,6 +14,7 @@ .content { height: 100%; overflow-y: auto; + padding-left: 0; padding-right: 0; } diff --git a/packages/chronicle/src/pages/ApiPage.tsx b/packages/chronicle/src/pages/ApiPage.tsx index 8143ea24..3fe06326 100644 --- a/packages/chronicle/src/pages/ApiPage.tsx +++ b/packages/chronicle/src/pages/ApiPage.tsx @@ -1,6 +1,6 @@ import { Flex, Headline, Text } from '@raystack/apsara'; import type { OpenAPIV3 } from 'openapi-types'; -import { EndpointPage } from '@/components/api'; +import { ApiOverview } from '@/components/api'; import { findApiOperation } from '@/lib/api-routes'; import { Head } from '@/lib/head'; import type { ApiSpec } from '@/lib/openapi'; @@ -36,7 +36,7 @@ export function ApiPage({ slug }: ApiPageProps) { return ( <> - { ? await response.json() : await response.text(); + const sensitiveHeaders = new Set(['set-cookie', 'authorization', 'proxy-authorization', 'cookie']); + const responseHeaders: Record = {}; + response.headers.forEach((v, k) => { + if (!sensitiveHeaders.has(k.toLowerCase())) responseHeaders[k] = v; + }); + return Response.json({ status: response.status, statusText: response.statusText, - body: responseBody + body: responseBody, + headers: responseHeaders }); } catch (error) { const message = diff --git a/packages/chronicle/src/themes/default/Layout.module.css b/packages/chronicle/src/themes/default/Layout.module.css index 911a157f..f5fcef22 100644 --- a/packages/chronicle/src/themes/default/Layout.module.css +++ b/packages/chronicle/src/themes/default/Layout.module.css @@ -226,3 +226,56 @@ .page { padding: var(--rs-space-2) 0; } + +.apiGroup { + margin-top: var(--rs-space-8); + width: 100%; +} + +.apiGroup:first-child { + margin-top: 0; +} + +.apiGroupLabel { + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + color: var(--rs-color-foreground-base-secondary); + padding: 0 var(--rs-space-3); +} + +.apiItem { + padding: var(--rs-space-3); + border-radius: var(--rs-radius-2); + text-decoration: none; + cursor: pointer; + white-space: nowrap; +} + +.apiItem:hover { + background: var(--rs-color-background-neutral-secondary); +} + +.apiItemActive { + background: var(--rs-color-background-neutral-secondary); +} + +.apiItemName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--rs-font-size-small); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + color: var(--rs-color-foreground-base-primary); +} + +.apiMethodText { + font-family: var(--rs-font-mono); + font-size: var(--rs-font-size-mono-mini); + line-height: var(--rs-line-height-mini); + flex-shrink: 0; +} diff --git a/packages/chronicle/src/themes/default/Layout.tsx b/packages/chronicle/src/themes/default/Layout.tsx index 00674b7b..8a6eef75 100644 --- a/packages/chronicle/src/themes/default/Layout.tsx +++ b/packages/chronicle/src/themes/default/Layout.tsx @@ -6,11 +6,15 @@ import { DocumentTextIcon, Squares2X2Icon } from '@heroicons/react/24/outline'; -import { Flex, IconButton, Sidebar } from '@raystack/apsara'; +import { Flex, IconButton, Button, Sidebar } from '@raystack/apsara'; +import { PlayIcon } from '@radix-ui/react-icons'; import { cx } from 'class-variance-authority'; -import { useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { Link as RouterLink, useLocation, useNavigate } from 'react-router'; +import type { OpenAPIV3 } from 'openapi-types'; import { MethodBadge } from '@/components/api/method-badge'; +import { useApiOperation } from '@/lib/use-api-operation'; +import { PlaygroundDialog } from '@/components/api/playground-dialog'; import { ClientThemeSwitcher } from '@/components/ui/client-theme-switcher'; import { Search } from '@/components/ui/search'; import { Breadcrumbs } from '@/components/ui/breadcrumbs'; @@ -151,11 +155,19 @@ export function Layout({
) : null} {tree.children.map((item, i) => ( - + isApiRoute ? ( + + ) : ( + + ) ))} {config.versions?.length ? ( @@ -190,7 +202,11 @@ export function Layout({
{!isApiRoute && } - + + {isApiRoute && } + {isApiRoute && } + +
{children} @@ -262,3 +278,115 @@ function SidebarNode({ ); } + +const methodColorMap: Record = { + 'method-get': 'var(--rs-color-foreground-success-primary)', + 'method-post': 'var(--rs-color-foreground-accent-primary)', + 'method-put': 'var(--rs-color-foreground-attention-primary)', + 'method-delete': 'var(--rs-color-foreground-danger-primary)', + 'method-patch': 'var(--rs-color-foreground-base-secondary)', +}; + +const methodLabelMap: Record = { + 'method-get': 'GET', + 'method-post': 'POST', + 'method-put': 'PUT', + 'method-delete': 'DEL', + 'method-patch': 'PATCH', +}; + +function ApiSidebarNode({ item, pathname }: { item: Node; pathname: string }) { + if (item.type === 'separator') return null; + + if (item.type === 'folder') { + return ( + + {item.name?.toString()} + + {item.children.map((child, i) => ( + + ))} + + + ); + } + + const isActive = pathname === item.url; + const href = item.url ?? '#'; + const iconKey = typeof item.icon === 'string' ? item.icon : ''; + const methodLabel = methodLabelMap[iconKey]; + const methodColor = methodColorMap[iconKey]; + + return ( + } + > + {item.name} + {methodLabel && ( + + {methodLabel} + + )} + + ); +} + +function TestRequestButton() { + const match = useApiOperation(); + const [open, setOpen] = useState(false); + if (!match) return null; + + return ( + <> + + + + ); +} + +function ViewDocsButton() { + const match = useApiOperation(); + if (!match) return null; + + const operation = match.operation as OpenAPIV3.OperationObject; + const docsUrl = operation.externalDocs?.url ?? match.spec.document.externalDocs?.url; + if (!docsUrl) return null; + + return ( + + ); +}