diff --git a/src/components/BaseUrlVariableToggle.tsx b/src/components/BaseUrlVariableToggle.tsx new file mode 100644 index 0000000..40a2127 --- /dev/null +++ b/src/components/BaseUrlVariableToggle.tsx @@ -0,0 +1,51 @@ +import { Switch } from "@/components/ui/switch" +import { Label } from "@/components/ui/label" +import { DEFAULT_BASE_URL_VARIABLE } from "./openapiImportShared" + +interface BaseUrlVariableToggleProps { + checked: boolean + onCheckedChange: (checked: boolean) => void + /** The concrete base URL, shown so the trade-off is visible before importing. */ + baseUrl: string +} + +/** + * Offers to write request URLs against a `{{baseUrl}}` variable rather than the + * absolute host. + * + * Defaulted on, because the alternative welds the collection to whichever + * environment happened to serve the spec — and the environments where that + * hurts most are the ones that do not expose a spec to import from at all. + */ +export function BaseUrlVariableToggle({ + checked, + onCheckedChange, + baseUrl, +}: BaseUrlVariableToggleProps) { + const example = checked + ? `{{${DEFAULT_BASE_URL_VARIABLE}}}/pet/findByStatus` + : `${(baseUrl || "https://api.example.com").replace(/\/+$/, "")}/pet/findByStatus` + + return ( +
+
+ + +
+

+ {checked + ? "Requests point at the variable, so one collection can be aimed at dev, test, stage or prod by switching environments." + : "Requests hard-code this host. The collection will only work against the environment you imported from."} +

+ + {example} + +
+ ) +} diff --git a/src/components/CollectionRunner.tsx b/src/components/CollectionRunner.tsx index f19d03e..d519672 100644 --- a/src/components/CollectionRunner.tsx +++ b/src/components/CollectionRunner.tsx @@ -4,7 +4,9 @@ import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" import { ScrollArea } from "@/components/ui/scroll-area" import { Card } from "@/components/ui/card" -import { SavedRequest, Response, TestResult } from "@/types" +import { Collection, SavedRequest, Response, TestResult } from "@/types" +import { applyAuthToHeaders } from "@/utils/authHeaders" +import { resolveRequestAuth } from "@/utils/collectionAuth" import { useCollectionStore } from "@/store/collections" import { useEnvironmentStore } from "@/store/environments" import { useSettingsStore } from "@/store/settings" @@ -83,7 +85,7 @@ export function CollectionRunner({ open, onOpenChange }: CollectionRunnerProps) ) const runRequest = useCallback( - async (request: SavedRequest): Promise => { + async (request: SavedRequest, collection?: Collection): Promise => { const startTime = performance.now() try { @@ -95,28 +97,15 @@ export function CollectionRunner({ open, onOpenChange }: CollectionRunnerProps) } }) - // Apply auth + // Apply auth, falling back to the collection's where the request + // does not carry its own — an imported spec relies on that. let url = substituteVariables(request.rawUrl || request.url) - if (request.auth.type === 'basic') { - const username = substituteVariables(request.auth.username || '') - const password = substituteVariables(request.auth.password || '') - const credentials = btoa(`${username}:${password}`) - headerRecord['Authorization'] = `Basic ${credentials}` - } else if (request.auth.type === 'bearer' && request.auth.token) { - headerRecord['Authorization'] = `Bearer ${substituteVariables(request.auth.token)}` - } else if (request.auth.type === 'api-key' && request.auth.key && request.auth.value) { - const key = substituteVariables(request.auth.key) - const value = substituteVariables(request.auth.value) - if (request.auth.addTo === 'header') { - headerRecord[key] = value - } else { - const separator = url.includes('?') ? '&' : '?' - url += `${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}` - } - } else if (request.auth.type === 'oauth2' && request.auth.oauth2?.accessToken) { - const tokenType = request.auth.oauth2.tokenType || 'Bearer' - headerRecord['Authorization'] = `${tokenType} ${substituteVariables(request.auth.oauth2.accessToken)}` - } + url = applyAuthToHeaders( + resolveRequestAuth(request, collection), + headerRecord, + url, + substituteVariables + ) // Cookie header const cookieHeader = request.cookies @@ -275,7 +264,7 @@ export function CollectionRunner({ open, onOpenChange }: CollectionRunnerProps) if (cancelRef.current) break setCurrentIndex(i + 1) - const result = await runRequest(selectedCollection.requests[i]) + const result = await runRequest(selectedCollection.requests[i], selectedCollection) setResults((prev) => [...prev, result]) } diff --git a/src/components/CollectionsPanel.tsx b/src/components/CollectionsPanel.tsx index cfab84c..b7df6e0 100644 --- a/src/components/CollectionsPanel.tsx +++ b/src/components/CollectionsPanel.tsx @@ -9,7 +9,8 @@ import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" import { FolderPlus, Download, Upload } from "lucide-react" import { useCollectionStore } from "@/store/collections" -import { Tab } from "@/types" +import { Collection, SavedRequest, Tab } from "@/types" +import { useEnvironmentStore } from "@/store/environments" import { getRequestNameFromUrl } from "@/utils/url" import { DropdownMenu, @@ -49,6 +50,8 @@ export const CollectionsPanel = forwardRef>(new Set()) const [openapiUrlModalOpen, setOpenapiUrlModalOpen] = useState(false) const [openapiRawModalOpen, setOpenapiRawModalOpen] = useState(false) @@ -91,15 +94,30 @@ export const CollectionsPanel = forwardRef[0]) => { - handleSelectRequest(savedRequestToTab(request)) + /** + * Switch to the collection's environment before opening anything from it. + * A collection written against `{{baseUrl}}` is meaningless without the + * environment that defines it, and silently sending a dev request at prod + * (or the reverse) is exactly the mistake worth designing out. + */ + const activateCollectionEnvironment = (collection?: Collection) => { + if (!collection?.environmentId) return + if (collection.environmentId === activeEnvironmentId) return + if (!environments.some((env) => env.id === collection.environmentId)) return + setActiveEnvironment(collection.environmentId) + } + + const handleSelectSavedRequest = (request: SavedRequest, collection?: Collection) => { + activateCollectionEnvironment(collection) + handleSelectRequest(savedRequestToTab(request, collection)) } const handleRestoreAllRequests = (collectionId: string) => { const targetCollection = collections.find((collection) => collection.id === collectionId) if (!targetCollection) return + activateCollectionEnvironment(targetCollection) targetCollection.requests.forEach((request) => { - onRequestSelect(savedRequestToTab(request)) + onRequestSelect(savedRequestToTab(request, targetCollection)) }) onOpenChange(false) } @@ -192,9 +210,13 @@ export const CollectionsPanel = forwardRef { + const handleOpenapiImport = ( + apiDoc: unknown, + baseUrl: string, + baseUrlVariable?: string + ) => { try { - const importedCollections = importFromOpenapi(apiDoc, baseUrl); + const importedCollections = importFromOpenapi(apiDoc, baseUrl, { baseUrlVariable }); const requestCount = importedCollections.reduce((sum, c) => sum + c.requests.length, 0); if (requestCount === 0) { @@ -202,10 +224,25 @@ export const CollectionsPanel = forwardRef env.id === activeEnvironmentId)?.name + variableNote = ` — {{${baseUrlVariable}}} set${envName ? ` in ${envName}` : ""}`; + } else { + variableNote = ` — set {{${baseUrlVariable}}} in an environment to use it`; + } + } + importCollections(importedCollections); setOpenapiUrlModalOpen(false); setOpenapiRawModalOpen(false); - toast.success(`Imported ${requestCount} request${requestCount === 1 ? "" : "s"} from OpenAPI`); + toast.success( + `Imported ${requestCount} request${requestCount === 1 ? "" : "s"} from OpenAPI${variableNote}` + ); } catch (error) { if (shouldLogImportErrors) { console.error("Error importing OpenAPI:", error); diff --git a/src/components/OpenapiImportModal.tsx b/src/components/OpenapiImportModal.tsx index e8f5229..36307cc 100644 --- a/src/components/OpenapiImportModal.tsx +++ b/src/components/OpenapiImportModal.tsx @@ -4,16 +4,19 @@ import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { useState } from "react" import { toast } from "sonner" +import { BaseUrlVariableToggle } from "./BaseUrlVariableToggle" +import { DEFAULT_BASE_URL_VARIABLE } from "./openapiImportShared" interface OpenapiImportModalProps { open: boolean onOpenChange: (open: boolean) => void - onImport: (openapiDoc: unknown, baseUrl: string) => void + onImport: (openapiDoc: unknown, baseUrl: string, baseUrlVariable?: string) => void } export function OpenapiImportModal({ open, onOpenChange, onImport }: OpenapiImportModalProps) { const [rawJSON, setRawJSON] = useState("") const [baseUrl, setBaseUrl] = useState("") + const [useVariable, setUseVariable] = useState(true) const handleImport = () => { if (!rawJSON.trim()) { @@ -35,9 +38,10 @@ export function OpenapiImportModal({ open, onOpenChange, onImport }: OpenapiImpo } try { - onImport(apiDoc, baseUrl) + onImport(apiDoc, baseUrl, useVariable ? DEFAULT_BASE_URL_VARIABLE : undefined) setRawJSON("") setBaseUrl("") + setUseVariable(true) } catch (error) { console.error("Error importing OpenAPI:", error) toast.error(error instanceof Error ? error.message : "Failed to import OpenAPI specification") @@ -66,6 +70,11 @@ export function OpenapiImportModal({ open, onOpenChange, onImport }: OpenapiImpo onChange={(e) => setBaseUrl(e.target.value)} className="bg-background text-foreground border-border placeholder:text-muted-foreground" /> + + + {showAuth && ( +
+ onUpdateCollection(collection.id, { auth })} + /> +

+ Requests in this collection use this unless they set their own. +

+
+ )} + + ) +} diff --git a/src/components/collections/collectionUtils.ts b/src/components/collections/collectionUtils.ts index 38413b3..317ebfc 100644 --- a/src/components/collections/collectionUtils.ts +++ b/src/components/collections/collectionUtils.ts @@ -1,4 +1,5 @@ -import { SavedRequest, Tab } from "@/types" +import { Collection, SavedRequest, Tab } from "@/types" +import { resolveRequestAuth } from "@/utils/collectionAuth" export const methodColors: Record = { GET: "bg-blue-500/10 text-blue-500", @@ -10,7 +11,16 @@ export const methodColors: Record = { OPTIONS: "bg-cyan-500/10 text-cyan-500", } -export function savedRequestToTab(request: SavedRequest): Tab { +/** + * Open a saved request as a tab. + * + * The collection is optional so existing callers keep working, but pass it + * where you can: it is what lets a request inherit the collection's auth. + * Resolution happens here rather than at send time so the Auth panel shows what + * will actually go out, instead of an empty form for a request that is in fact + * authenticated. + */ +export function savedRequestToTab(request: SavedRequest, collection?: Collection): Tab { return { id: crypto.randomUUID(), name: request.name, @@ -21,7 +31,7 @@ export function savedRequestToTab(request: SavedRequest): Tab { headers: request.headers, body: request.body, contentType: request.contentType, - auth: request.auth, + auth: resolveRequestAuth(request, collection), cookies: request.cookies, loading: false, response: null, diff --git a/src/components/openapiImportShared.ts b/src/components/openapiImportShared.ts new file mode 100644 index 0000000..0d95604 --- /dev/null +++ b/src/components/openapiImportShared.ts @@ -0,0 +1,7 @@ +/** + * The environment variable name an OpenAPI import writes request URLs against. + * + * Kept in its own module so the two import modals and the toggle can share it + * without importing components from each other. + */ +export const DEFAULT_BASE_URL_VARIABLE = "baseUrl" diff --git a/src/hooks/useRequest.ts b/src/hooks/useRequest.ts index 64d7fe7..1eec06a 100644 --- a/src/hooks/useRequest.ts +++ b/src/hooks/useRequest.ts @@ -3,6 +3,7 @@ import { Tab, HistoryItem } from '@/types' import { useEnvironmentStore } from '@/store/environments' import { useSettingsStore } from '@/store/settings' import { substituteVariables as substitute } from '@/utils/variables' +import { applyAuthToHeaders, setHeader } from '@/utils/authHeaders' interface RedirectInfo { url: string @@ -51,25 +52,6 @@ export function useRequest(onHistoryUpdate: (item: HistoryItem) => void) { const substituteVariables = (text: string): string => substitute(text, getVariable) - /** - * Set a header, replacing any existing key that differs only in case. - * - * HTTP header names are case-insensitive but a JS object's keys are not, so a - * plain assignment to `Authorization` leaves a user-typed `authorization` - * sitting right next to it and both get sent. Servers are free to pick either - * one, which shows up as an intermittent 401 that looks like the auth config - * is broken when it is actually a stale header from the Headers tab. - */ - const setHeader = (headers: Record, key: string, value: string) => { - const lowered = key.toLowerCase() - for (const existing of Object.keys(headers)) { - if (existing !== key && existing.toLowerCase() === lowered) { - delete headers[existing] - } - } - headers[key] = value - } - const sendRequest = async (tab: Tab) => { if (!tab.rawUrl) return null @@ -84,26 +66,7 @@ export function useRequest(onHistoryUpdate: (item: HistoryItem) => void) { }) // Handle authentication with variable substitution - if (tab.auth.type === 'basic') { - const username = substituteVariables(tab.auth.username || '') - const password = substituteVariables(tab.auth.password || '') - const credentials = btoa(`${username}:${password}`) - setHeader(headerRecord, 'Authorization', `Basic ${credentials}`) - } else if (tab.auth.type === 'bearer' && tab.auth.token) { - setHeader(headerRecord, 'Authorization', `Bearer ${substituteVariables(tab.auth.token)}`) - } else if (tab.auth.type === 'api-key' && tab.auth.key && tab.auth.value) { - const key = substituteVariables(tab.auth.key) - const value = substituteVariables(tab.auth.value) - if (tab.auth.addTo === 'header') { - setHeader(headerRecord, key, value) - } else { - const separator = url.includes('?') ? '&' : '?' - url += `${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}` - } - } else if (tab.auth.type === 'oauth2' && tab.auth.oauth2?.accessToken) { - const tokenType = tab.auth.oauth2.tokenType || 'Bearer' - setHeader(headerRecord, 'Authorization', `${tokenType} ${tab.auth.oauth2.accessToken}`) - } + url = applyAuthToHeaders(tab.auth, headerRecord, url, substituteVariables) // Add cookies to headers with variable substitution const cookieHeader = tab.cookies diff --git a/src/test/OpenapiImport.test.tsx b/src/test/OpenapiImport.test.tsx index 9a888d2..6cf5d71 100644 --- a/src/test/OpenapiImport.test.tsx +++ b/src/test/OpenapiImport.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import { describe, it, expect, beforeEach, vi } from 'vitest' import { OpenapiImportModal } from '@/components/OpenapiImportModal' import { CollectionsPanel } from '@/components/CollectionsPanel' +import { importFromOpenapi } from '@/utils/collection-converter' import { toast } from 'sonner' // Mock the toast @@ -111,7 +112,8 @@ describe('OpenapiImportModal', () => { fireEvent.change(baseUrlInput, { target: { value: 'https://api.example.com' } }) fireEvent.click(screen.getByRole('button', { name: /import/i })) - expect(mockOnImport).toHaveBeenCalledWith({ valid: 'json' }, 'https://api.example.com') + // Third argument is the {{baseUrl}} variable name, on by default. + expect(mockOnImport).toHaveBeenCalledWith({ valid: 'json' }, 'https://api.example.com', 'baseUrl') expect(textarea).toHaveValue('') expect(baseUrlInput).toHaveValue('') }) @@ -158,7 +160,52 @@ describe('CollectionsPanel OpenAPI Import', () => { fireEvent.click(screen.getByRole('button', { name: /^import$/i })) await waitFor(() => { - expect(toast.success).toHaveBeenCalledWith('Imported 1 request from OpenAPI') + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Imported 1 request')) + }) + }) + + it('parameterizes the base URL by default so the collection is not welded to one host', async () => { + const user = userEvent.setup() + mockFetch.mockResolvedValueOnce(okResponse(SPEC)) + + render( { }} onRequestSelect={() => { }} />) + + const urlInput = await openUrlModal(user) + fireEvent.change(urlInput, { target: { value: 'https://api.example.com/swagger/v1/swagger.json' } }) + fireEvent.click(screen.getByRole('button', { name: /^load$/i })) + await waitFor(() => expect(screen.getByPlaceholderText(/base url/i)).toHaveValue('https://api.example.com/')) + + fireEvent.click(screen.getByRole('button', { name: /^import$/i })) + + await waitFor(() => { + expect(importFromOpenapi).toHaveBeenCalledWith( + expect.anything(), + 'https://api.example.com/', + { baseUrlVariable: 'baseUrl' } + ) + }) + }) + + it('bakes in the absolute host when the toggle is turned off', async () => { + const user = userEvent.setup() + mockFetch.mockResolvedValueOnce(okResponse(SPEC)) + + render( { }} onRequestSelect={() => { }} />) + + const urlInput = await openUrlModal(user) + fireEvent.change(urlInput, { target: { value: 'https://api.example.com/swagger/v1/swagger.json' } }) + fireEvent.click(screen.getByRole('button', { name: /^load$/i })) + await waitFor(() => expect(screen.getByPlaceholderText(/base url/i)).toHaveValue('https://api.example.com/')) + + fireEvent.click(screen.getByTestId('base-url-variable-toggle')) + fireEvent.click(screen.getByRole('button', { name: /^import$/i })) + + await waitFor(() => { + expect(importFromOpenapi).toHaveBeenCalledWith( + expect.anything(), + 'https://api.example.com/', + { baseUrlVariable: undefined } + ) }) }) diff --git a/src/test/collectionAuth.test.ts b/src/test/collectionAuth.test.ts new file mode 100644 index 0000000..9adad6a --- /dev/null +++ b/src/test/collectionAuth.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest' +import { resolveRequestAuth, isInheritingAuth } from '@/utils/collectionAuth' +import { applyAuthToHeaders, setHeader } from '@/utils/authHeaders' +import { importFromOpenapi } from '@/utils/collection-converter' +import { AuthConfig } from '@/types' + +const collectionAuth: AuthConfig = { + type: 'oauth2', + oauth2: { grantType: 'client_credentials', clientId: '{{clientId}}', accessToken: 'collection-token' }, +} +const requestAuth: AuthConfig = { type: 'bearer', token: 'request-token' } + +describe('resolveRequestAuth', () => { + it('uses the collection auth when the request inherits', () => { + expect(resolveRequestAuth({ auth: { type: 'none' }, authMode: 'inherit' }, { auth: collectionAuth })) + .toEqual(collectionAuth) + }) + + it('uses the request auth when it overrides', () => { + expect(resolveRequestAuth({ auth: requestAuth, authMode: 'override' }, { auth: collectionAuth })) + .toEqual(requestAuth) + }) + + it('sends nothing when inheriting from a collection with no auth', () => { + expect(resolveRequestAuth({ auth: requestAuth, authMode: 'inherit' }, {})) + .toEqual({ type: 'none' }) + }) + + // Collections saved before collection-level auth existed have no authMode. + // Those requests must keep behaving exactly as they did. + describe('requests saved before authMode existed', () => { + it('keeps its own auth when it has any', () => { + expect(resolveRequestAuth({ auth: requestAuth }, { auth: collectionAuth })).toEqual(requestAuth) + }) + + it('falls back to the collection only when it had nothing to send', () => { + expect(resolveRequestAuth({ auth: { type: 'none' } }, { auth: collectionAuth })) + .toEqual(collectionAuth) + }) + + it('is unchanged when there is no collection auth either', () => { + expect(resolveRequestAuth({ auth: { type: 'none' } }, {})).toEqual({ type: 'none' }) + }) + }) + + it('tolerates a missing collection entirely', () => { + expect(resolveRequestAuth({ auth: requestAuth, authMode: 'inherit' })).toEqual({ type: 'none' }) + }) +}) + +describe('isInheritingAuth', () => { + it('reports inheritance accurately across the modes', () => { + expect(isInheritingAuth({ auth: { type: 'none' }, authMode: 'inherit' }, { auth: collectionAuth })).toBe(true) + expect(isInheritingAuth({ auth: requestAuth, authMode: 'override' }, { auth: collectionAuth })).toBe(false) + expect(isInheritingAuth({ auth: requestAuth }, { auth: collectionAuth })).toBe(false) + expect(isInheritingAuth({ auth: { type: 'none' } }, { auth: collectionAuth })).toBe(true) + expect(isInheritingAuth({ auth: { type: 'none' } }, {})).toBe(false) + }) +}) + +describe('applyAuthToHeaders', () => { + const sub = (text: string) => text.replace('{{token}}', 'resolved') + + it('replaces a case-variant header rather than sending both', () => { + const headers: Record = { authorization: 'Bearer stale' } + applyAuthToHeaders({ type: 'bearer', token: 'fresh' }, headers, 'https://x', sub) + + expect(Object.keys(headers).filter((k) => k.toLowerCase() === 'authorization')).toHaveLength(1) + expect(headers['Authorization']).toBe('Bearer fresh') + }) + + it('substitutes variables in authored fields', () => { + const headers: Record = {} + applyAuthToHeaders({ type: 'bearer', token: '{{token}}' }, headers, 'https://x', sub) + expect(headers['Authorization']).toBe('Bearer resolved') + }) + + it('appends an api key to the query string when configured that way', () => { + const headers: Record = {} + const url = applyAuthToHeaders( + { type: 'api-key', key: 'code', value: 'abc', addTo: 'query' }, + headers, 'https://x/api?a=1', sub + ) + expect(url).toBe('https://x/api?a=1&code=abc') + expect(headers).toEqual({}) + }) + + it('leaves the request untouched for type none', () => { + const headers: Record = { Accept: 'application/json' } + const url = applyAuthToHeaders({ type: 'none' }, headers, 'https://x', sub) + expect(headers).toEqual({ Accept: 'application/json' }) + expect(url).toBe('https://x') + }) + + it('sends nothing for oauth2 with no token yet', () => { + const headers: Record = {} + applyAuthToHeaders( + { type: 'oauth2', oauth2: { grantType: 'client_credentials', clientId: 'c' } }, + headers, 'https://x', sub + ) + expect(headers).toEqual({}) + }) +}) + +describe('setHeader', () => { + it('is a no-op replacement when the case already matches', () => { + const headers: Record = { Authorization: 'a' } + setHeader(headers, 'Authorization', 'b') + expect(headers).toEqual({ Authorization: 'b' }) + }) + + it('does not disturb unrelated headers', () => { + const headers: Record = { Accept: 'json', 'x-trace': '1' } + setHeader(headers, 'Authorization', 'a') + expect(headers).toEqual({ Accept: 'json', 'x-trace': '1', Authorization: 'a' }) + }) +}) + +describe('importFromOpenapi base URL parameterization', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Test API' }, + paths: { + '/pet/findByStatus': { get: { summary: 'Finds Pets by status.' } }, + '/pet/{petId}': { get: { summary: 'Find pet by ID.' } }, + }, + } + + it('writes URLs against the variable instead of the host', () => { + const [collection] = importFromOpenapi(spec, 'https://dev-api.corp/v1', { + baseUrlVariable: 'baseUrl', + }) + + expect(collection.requests.map((r) => r.url)).toEqual([ + '{{baseUrl}}/pet/findByStatus', + '{{baseUrl}}/pet/{petId}', + ]) + // rawUrl is what actually gets sent, so it must carry the variable too. + expect(collection.requests[0].rawUrl).toBe('{{baseUrl}}/pet/findByStatus') + }) + + it('marks imported requests as inheriting the collection auth', () => { + // Otherwise every operation in the spec needs OAuth configured by hand. + const [collection] = importFromOpenapi(spec, 'https://dev-api.corp', { baseUrlVariable: 'baseUrl' }) + expect(collection.requests.every((r) => r.authMode === 'inherit')).toBe(true) + }) + + it('still bakes in the absolute host when no variable is requested', () => { + const [collection] = importFromOpenapi(spec, 'https://dev-api.corp/v1') + expect(collection.requests[0].url).toBe('https://dev-api.corp/v1/pet/findByStatus') + }) + + it('does not double up slashes when the base URL has a trailing one', () => { + const [collection] = importFromOpenapi(spec, 'https://dev-api.corp/', { baseUrlVariable: 'baseUrl' }) + expect(collection.requests[0].url).toBe('{{baseUrl}}/pet/findByStatus') + }) +}) diff --git a/src/test/collectionEnvironment.test.tsx b/src/test/collectionEnvironment.test.tsx new file mode 100644 index 0000000..188015d --- /dev/null +++ b/src/test/collectionEnvironment.test.tsx @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { savedRequestToTab } from '@/components/collections/collectionUtils' +import { CollectionSettings } from '@/components/collections/CollectionSettings' +import { AuthConfig, Collection, SavedRequest } from '@/types' + +const setActiveEnvironment = vi.fn() +const environments = [ + { id: 'env-dev', name: 'dev', variables: { baseUrl: 'https://dev-api.corp' } }, + { id: 'env-prod', name: 'prod', variables: { baseUrl: 'https://api.corp' } }, +] + +vi.mock('@/store/environments', () => ({ + useEnvironmentStore: () => ({ + environments, + activeEnvironmentId: 'env-dev', + setActiveEnvironment, + getVariable: (key: string) => environments[0].variables[key as 'baseUrl'], + }), +})) + +const oauth: AuthConfig = { + type: 'oauth2', + oauth2: { grantType: 'client_credentials', clientId: '{{clientId}}', accessToken: 'tok' }, +} + +function makeRequest(overrides: Partial = {}): SavedRequest { + return { + id: 'r1', + name: '/pet — Add a pet', + method: 'POST', + url: '{{baseUrl}}/pet', + rawUrl: '{{baseUrl}}/pet', + params: [], + headers: [], + body: '', + contentType: 'application/json', + auth: { type: 'none' }, + cookies: [], + testScripts: [], + testAssertions: [], + testResults: null, + ...overrides, + } +} + +function makeCollection(overrides: Partial = {}): Collection { + return { id: 'c1', name: 'Petstore', requests: [], ...overrides } +} + +describe('savedRequestToTab auth inheritance', () => { + it('opens an inheriting request with the collection auth', () => { + // Without this an imported spec opens with an empty Auth tab, so the panel + // shows something different from what will actually be sent. + const tab = savedRequestToTab( + makeRequest({ authMode: 'inherit' }), + makeCollection({ auth: oauth }) + ) + expect(tab.auth).toEqual(oauth) + }) + + it('keeps an overriding request on its own auth', () => { + const own: AuthConfig = { type: 'bearer', token: 'mine' } + const tab = savedRequestToTab( + makeRequest({ auth: own, authMode: 'override' }), + makeCollection({ auth: oauth }) + ) + expect(tab.auth).toEqual(own) + }) + + it('still works when called without a collection', () => { + // The old single-argument call sites must keep compiling and behaving. + const tab = savedRequestToTab(makeRequest({ auth: { type: 'bearer', token: 't' } })) + expect(tab.auth).toEqual({ type: 'bearer', token: 't' }) + }) + + it('carries the parameterized URL through untouched', () => { + // Substitution happens at send time, not here. + const tab = savedRequestToTab(makeRequest(), makeCollection()) + expect(tab.rawUrl).toBe('{{baseUrl}}/pet') + }) +}) + +describe('CollectionSettings', () => { + const onUpdateCollection = vi.fn() + + beforeEach(() => { + onUpdateCollection.mockReset() + setActiveEnvironment.mockReset() + }) + + it('lists the available environments and the current link', () => { + render( + + ) + expect(screen.getByLabelText(/Environment for Petstore/)).toHaveTextContent('prod') + expect(screen.getByText(/Activated automatically/)).toBeInTheDocument() + }) + + it('summarises the collection auth without expanding it', () => { + render( + + ) + expect(screen.getByText('OAuth 2.0 · token held')).toBeInTheDocument() + }) + + it('distinguishes an OAuth config that has not fetched a token yet', () => { + const noToken: AuthConfig = { + type: 'oauth2', + oauth2: { grantType: 'client_credentials', clientId: 'c' }, + } + render( + + ) + expect(screen.getByText('OAuth 2.0 · no token yet')).toBeInTheDocument() + }) + + it('reveals the full auth editor on demand', () => { + render() + + expect(screen.queryByText(/use this unless they set their own/)).not.toBeInTheDocument() + fireEvent.click(screen.getByLabelText(/Show auth for Petstore/)) + expect(screen.getByText(/use this unless they set their own/)).toBeInTheDocument() + }) + + it('shows no environment hint when the collection is not linked', () => { + render() + expect(screen.queryByText(/Activated automatically/)).not.toBeInTheDocument() + }) +}) diff --git a/src/test/openapiExample.test.ts b/src/test/openapiExample.test.ts new file mode 100644 index 0000000..9bbd7de --- /dev/null +++ b/src/test/openapiExample.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest' +import { exampleBodyFor, pickContentType, makeRefResolver } from '@/utils/openapiExample' +import { importFromOpenapi } from '@/utils/collection-converter' + +const doc = { + components: { + schemas: { + Pet: { + type: 'object', + properties: { + id: { type: 'integer', format: 'int64' }, + name: { type: 'string' }, + category: { $ref: '#/components/schemas/Category' }, + photoUrls: { type: 'array', items: { type: 'string' } }, + status: { type: 'string', enum: ['available', 'pending', 'sold'] }, + }, + }, + Category: { + type: 'object', + properties: { id: { type: 'integer' }, name: { type: 'string' } }, + }, + // Self-referential: a category that contains categories. + Node: { + type: 'object', + properties: { + name: { type: 'string' }, + children: { type: 'array', items: { $ref: '#/components/schemas/Node' } }, + }, + }, + // Mutually recursive pair. + A: { type: 'object', properties: { b: { $ref: '#/components/schemas/B' } } }, + B: { type: 'object', properties: { a: { $ref: '#/components/schemas/A' } } }, + }, + }, +} + +const bodyFor = (schema: unknown) => JSON.parse(exampleBodyFor({ schema }, doc) || 'null') + +describe('exampleBodyFor', () => { + it('expands a $ref into the full shape', () => { + expect(bodyFor({ $ref: '#/components/schemas/Pet' })).toEqual({ + id: 0, + name: 'string', + category: { id: 0, name: 'string' }, + photoUrls: ['string'], + status: 'available', // first enum value, not an invented string + }) + }) + + it('prefers a spec-provided example over anything generated', () => { + const explicit = { name: 'Fido', id: 7 } + expect(JSON.parse(exampleBodyFor({ schema: { $ref: '#/components/schemas/Pet' }, example: explicit }, doc))) + .toEqual(explicit) + }) + + it('reads the first entry of an examples map', () => { + const media = { schema: { type: 'string' }, examples: { ok: { value: { hello: 'world' } } } } + expect(JSON.parse(exampleBodyFor(media, doc))).toEqual({ hello: 'world' }) + }) + + it('honours default over a generated placeholder', () => { + expect(bodyFor({ type: 'object', properties: { n: { type: 'integer', default: 42 } } })) + .toEqual({ n: 42 }) + }) + + it('uses formats to make strings plausible', () => { + expect(bodyFor({ + type: 'object', + properties: { + when: { type: 'string', format: 'date-time' }, + id: { type: 'string', format: 'uuid' }, + mail: { type: 'string', format: 'email' }, + }, + })).toEqual({ + when: '1970-01-01T00:00:00Z', + id: '00000000-0000-0000-0000-000000000000', + mail: 'user@example.com', + }) + }) + + it('merges allOf composition into one object', () => { + expect(bodyFor({ + allOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'boolean' } } }, + ], + })).toEqual({ a: 'string', b: false }) + }) + + it('takes the first branch of oneOf', () => { + expect(bodyFor({ oneOf: [{ type: 'string' }, { type: 'integer' }] })).toBe('string') + }) + + it('omits readOnly properties, which a request body must not send', () => { + expect(bodyFor({ + type: 'object', + properties: { id: { type: 'integer', readOnly: true }, name: { type: 'string' } }, + })).toEqual({ name: 'string' }) + }) + + it('handles a 3.1 nullable type union', () => { + expect(bodyFor({ type: 'object', properties: { n: { type: ['string', 'null'] } } })) + .toEqual({ n: 'string' }) + }) + + describe('recursion', () => { + // These are the cases that turn a naive generator into a stack overflow. + it('terminates on a self-referential schema', () => { + const result = bodyFor({ $ref: '#/components/schemas/Node' }) + expect(result).toEqual({ name: 'string', children: [null] }) + }) + + it('terminates on a mutually recursive pair', () => { + expect(bodyFor({ $ref: '#/components/schemas/A' })).toEqual({ b: { a: null } }) + }) + + it('survives a $ref that does not resolve', () => { + expect(bodyFor({ $ref: '#/components/schemas/Nope' })).toBeNull() + }) + + it('does not chase external refs', () => { + expect(bodyFor({ $ref: 'https://example.com/schema.json#/Pet' })).toBeNull() + }) + }) + + it('returns an empty string when there is no schema at all', () => { + expect(exampleBodyFor({}, doc)).toBe('') + expect(exampleBodyFor(null, doc)).toBe('') + }) +}) + +describe('makeRefResolver', () => { + it('resolves a components pointer', () => { + expect(makeRefResolver(doc)('#/components/schemas/Category')).toEqual( + doc.components.schemas.Category + ) + }) + + it('unescapes JSON Pointer segments', () => { + const escaped = { paths: { '/pet': { get: { type: 'object' } } } } + expect(makeRefResolver(escaped)('#/paths/~1pet/get')).toEqual({ type: 'object' }) + }) +}) + +describe('pickContentType', () => { + it('prefers JSON over whatever is listed first', () => { + // Swashbuckle commonly lists xml before json for the same operation. + expect(pickContentType(['application/xml', 'application/json'])).toBe('application/json') + }) + + it('recognises +json suffixes', () => { + expect(pickContentType(['application/xml', 'application/merge-patch+json'])) + .toBe('application/merge-patch+json') + }) + + it('falls back to the first when there is no JSON', () => { + expect(pickContentType(['application/xml', 'text/plain'])).toBe('application/xml') + expect(pickContentType([])).toBeUndefined() + }) +}) + +describe('importFromOpenapi request bodies', () => { + const spec = { + openapi: '3.0.0', + info: { title: 'Test' }, + components: doc.components, + paths: { + '/pet': { + post: { + summary: 'Add a pet', + requestBody: { + content: { + 'application/xml': { schema: { $ref: '#/components/schemas/Pet' } }, + 'application/json': { schema: { $ref: '#/components/schemas/Pet' } }, + }, + }, + }, + }, + '/ping': { get: { summary: 'Ping' } }, + }, + } + + it('fills the body in and picks the JSON content type', () => { + const [collection] = importFromOpenapi(spec, 'https://api.example.com') + const post = collection.requests.find((r) => r.method === 'POST')! + + expect(post.contentType).toBe('application/json') + expect(JSON.parse(post.body).name).toBe('string') + expect(post.body).toContain('\n') // formatted, not a single line + }) + + it('leaves a GET with no request body empty', () => { + const [collection] = importFromOpenapi(spec, 'https://api.example.com') + const get = collection.requests.find((r) => r.method === 'GET')! + expect(get.body).toBe('') + }) + + it('does not generate a JSON body for an XML-only endpoint', () => { + const xmlOnly = { + ...spec, + paths: { + '/x': { + post: { requestBody: { content: { 'application/xml': { schema: { type: 'object' } } } } }, + }, + }, + } + const [collection] = importFromOpenapi(xmlOnly, 'https://api.example.com') + expect(collection.requests[0].contentType).toBe('application/xml') + expect(collection.requests[0].body).toBe('') + }) +}) diff --git a/src/types/index.ts b/src/types/index.ts index 0d40450..7fbf7b3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -254,11 +254,23 @@ export interface Cookie { httpOnly?: boolean } +/** + * Whether a saved request uses its own auth or the collection's. + * + * Absent means "not recorded" — see resolveRequestAuth, which keeps requests + * saved before collection-level auth existed behaving exactly as they did. + */ +export type AuthMode = 'inherit' | 'override' + export interface Collection { id: string name: string description?: string requests: SavedRequest[] + /** Applied to requests that do not override it. */ + auth?: AuthConfig + /** Activated automatically when a request from this collection is opened. */ + environmentId?: string createdAt?: Date updatedAt?: Date } @@ -274,6 +286,7 @@ export interface SavedRequest { body: string contentType: string auth: AuthConfig + authMode?: AuthMode cookies: Cookie[] testScripts: TestScript[] preRequestScripts?: TestScript[] diff --git a/src/utils/authHeaders.ts b/src/utils/authHeaders.ts new file mode 100644 index 0000000..336c548 --- /dev/null +++ b/src/utils/authHeaders.ts @@ -0,0 +1,70 @@ +import { AuthConfig } from '@/types' + +/** + * Set a header, replacing any existing key that differs only in case. + * + * HTTP header names are case-insensitive but a JS object's keys are not, so a + * plain assignment to `Authorization` leaves a user-typed `authorization` + * sitting right next to it and both get sent. Servers are free to pick either + * one, which shows up as an intermittent 401 that looks like the auth config is + * broken when it is actually a stale header from the Headers tab. + */ +export function setHeader(headers: Record, key: string, value: string) { + const lowered = key.toLowerCase() + for (const existing of Object.keys(headers)) { + if (existing !== key && existing.toLowerCase() === lowered) { + delete headers[existing] + } + } + headers[key] = value +} + +/** + * Apply an auth config to a header map, mutating it in place. + * + * Shared by the single-request path and the collection runner, which had + * grown independent copies of this logic — and so disagreed about the case + * handling above. + * + * @returns the URL, which an api-key auth set to `query` will have appended to. + */ +export function applyAuthToHeaders( + auth: AuthConfig | undefined, + headers: Record, + url: string, + substitute: (text: string) => string +): string { + if (!auth) return url + + if (auth.type === 'basic') { + const username = substitute(auth.username || '') + const password = substitute(auth.password || '') + setHeader(headers, 'Authorization', `Basic ${btoa(`${username}:${password}`)}`) + return url + } + + if (auth.type === 'bearer' && auth.token) { + setHeader(headers, 'Authorization', `Bearer ${substitute(auth.token)}`) + return url + } + + if (auth.type === 'api-key' && auth.key && auth.value) { + const key = substitute(auth.key) + const value = substitute(auth.value) + if (auth.addTo === 'header') { + setHeader(headers, key, value) + return url + } + const separator = url.includes('?') ? '&' : '?' + return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}` + } + + if (auth.type === 'oauth2' && auth.oauth2?.accessToken) { + // The token is issued, not authored, so it is used verbatim — no + // substitution pass over a value the user never typed. + const tokenType = auth.oauth2.tokenType || 'Bearer' + setHeader(headers, 'Authorization', `${tokenType} ${auth.oauth2.accessToken}`) + } + + return url +} diff --git a/src/utils/collection-converter.ts b/src/utils/collection-converter.ts index 0bb5c11..77087b6 100644 --- a/src/utils/collection-converter.ts +++ b/src/utils/collection-converter.ts @@ -1,4 +1,5 @@ import { Collection, URLParam, Header, AuthConfig, AuthType, Cookie } from '@/types' +import { exampleBodyFor, pickContentType } from '@/utils/openapiExample' interface PostmanCollection { info: { @@ -215,7 +216,22 @@ function assertOpenapiDocument(doc: unknown): asserts doc is Record } } -export function importFromOpenapi(openapiDoc: any, baseUrl: string): Collection[] { +export interface OpenapiImportOptions { + /** + * Write request URLs as `{{name}}/path` instead of baking in the absolute + * host, so one collection can be pointed at dev/test/stage/prod by switching + * environments. Without it a collection is welded to whichever host its spec + * was fetched from — which is a problem precisely where it matters most, on + * the environments that do not expose a spec to import from. + */ + baseUrlVariable?: string; +} + +export function importFromOpenapi( + openapiDoc: any, + baseUrl: string, + options: OpenapiImportOptions = {} +): Collection[] { assertOpenapiDocument(openapiDoc); const collections: Collection[] = []; @@ -231,6 +247,16 @@ export function importFromOpenapi(openapiDoc: any, baseUrl: string): Collection[ }; const serverUrl = baseUrl; + const baseUrlVariable = options.baseUrlVariable?.trim(); + + /** + * `{{baseUrl}}/pet/findByStatus`. The variable reference is concatenated + * rather than run through `new URL()`, which would reject `{{baseUrl}}` as an + * invalid scheme. Trailing and leading slashes are normalised so the result + * is right whether the user's variable ends in `/` or not. + */ + const parameterizedUrl = (path: string) => + `{{${baseUrlVariable}}}/${path.replace(/^\/+/, '')}`; const paths = openapiDoc.paths || {}; for (const path in paths) { @@ -248,7 +274,9 @@ export function importFromOpenapi(openapiDoc: any, baseUrl: string): Collection[ : path; let fullUrl = path; try { - if (serverUrl) { + if (baseUrlVariable) { + fullUrl = parameterizedUrl(path); + } else if (serverUrl) { // Ensure serverUrl ends with / to preserve base path const base = serverUrl.endsWith('/') ? serverUrl : serverUrl + '/'; // Remove leading slash from path to prevent base path erasure @@ -267,11 +295,17 @@ export function importFromOpenapi(openapiDoc: any, baseUrl: string): Collection[ enabled: true })); let contentType = "application/json"; - const body = ""; + let body = ""; if (operation.requestBody && operation.requestBody.content) { - const contentTypes = Object.keys(operation.requestBody.content); - if (contentTypes.length > 0) { - contentType = contentTypes[0]; + const chosen = pickContentType(Object.keys(operation.requestBody.content)); + if (chosen) { + contentType = chosen; + // Only JSON bodies are generated. The example generator emits a JSON + // value, and handing that to an endpoint expecting XML or form + // encoding would be worse than leaving the body empty. + if (/json/i.test(chosen)) { + body = exampleBodyFor(operation.requestBody.content[chosen], openapiDoc); + } } } const newRequest = { @@ -285,6 +319,10 @@ export function importFromOpenapi(openapiDoc: any, baseUrl: string): Collection[ body, contentType, auth: { type: 'none' } as AuthConfig, + // Every operation in a spec sits behind the same API and the same + // OAuth app, so they inherit by default — otherwise importing means + // configuring auth once per endpoint, dozens of times. + authMode: 'inherit' as const, cookies: [], testScripts: [], testAssertions: [], diff --git a/src/utils/collectionAuth.ts b/src/utils/collectionAuth.ts new file mode 100644 index 0000000..1a3754f --- /dev/null +++ b/src/utils/collectionAuth.ts @@ -0,0 +1,47 @@ +import { AuthConfig, Collection, SavedRequest } from '@/types' + +const NO_AUTH: AuthConfig = { type: 'none' } + +/** + * Work out which auth a saved request actually sends. + * + * The motivating case is an OpenAPI import: it produces dozens of requests that + * all hit the same API behind the same OAuth app, and configuring each one by + * hand is not a reasonable thing to ask of anyone. + * + * `authMode` is optional because collections predate it. When it is absent the + * request's own auth wins if it has any — that is precisely how these requests + * behaved before collection-level auth existed, so nothing already saved + * changes behaviour. Only a request that had nothing to send (`type: 'none'`) + * falls through to the collection, where inheriting can lose nothing. + */ +export function resolveRequestAuth( + request: Pick, + collection?: Pick +): AuthConfig { + if (request.authMode === 'override') { + return request.auth ?? NO_AUTH + } + if (request.authMode === 'inherit') { + return collection?.auth ?? NO_AUTH + } + + // Not recorded: preserve the pre-existing behaviour. + if (request.auth && request.auth.type !== 'none') { + return request.auth + } + return collection?.auth ?? request.auth ?? NO_AUTH +} + +/** + * Whether a request is currently taking its auth from the collection, for + * showing the user where the auth they are about to send comes from. + */ +export function isInheritingAuth( + request: Pick, + collection?: Pick +): boolean { + if (request.authMode === 'override') return false + if (request.authMode === 'inherit') return true + return !(request.auth && request.auth.type !== 'none') && !!collection?.auth +} diff --git a/src/utils/openapiExample.ts b/src/utils/openapiExample.ts new file mode 100644 index 0000000..c420279 --- /dev/null +++ b/src/utils/openapiExample.ts @@ -0,0 +1,207 @@ +/** + * Build an example request body from an OpenAPI schema. + * + * An imported request that arrives with an empty body is barely a starting + * point — you still have to go and read the spec to find out what the endpoint + * wants. The spec already says, so fill it in. + * + * This produces a *shape*, not valid data: the point is to show the field names + * and types so they can be edited, not to pass validation. Where the spec + * offers something concrete (`example`, `default`, an `enum`) that is used in + * preference to an invented value. + */ + +type Schema = Record + +/** How deep to follow nested schemas before giving up. */ +const MAX_DEPTH = 8 + +interface GenerateContext { + /** Resolves `#/components/schemas/Pet` to its schema object. */ + resolve: (ref: string) => Schema | undefined + /** + * `$ref`s currently being expanded on this branch. + * + * Self-referential schemas are entirely normal — a tree node with children of + * its own type, or Pet → Category → Pet. Without this the generator recurses + * until the stack gives out. + */ + active: Set + depth: number +} + +function isSchema(value: unknown): value is Schema { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +/** A placeholder for a string, informed by `format` where one is given. */ +function exampleString(schema: Schema): string { + switch (schema.format) { + case 'date-time': + return '1970-01-01T00:00:00Z' + case 'date': + return '1970-01-01' + case 'uuid': + return '00000000-0000-0000-0000-000000000000' + case 'email': + return 'user@example.com' + case 'uri': + case 'url': + return 'https://example.com' + case 'byte': + return '' + case 'password': + return '' + default: + return 'string' + } +} + +function generate(schema: unknown, ctx: GenerateContext): unknown { + if (!isSchema(schema)) return null + + // Anything the spec states outright beats anything invented here. + if (schema.example !== undefined) return schema.example + if (schema.default !== undefined) return schema.default + if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0] + + if (typeof schema.$ref === 'string') { + const ref = schema.$ref + if (ctx.active.has(ref) || ctx.depth >= MAX_DEPTH) { + // Cycle, or deep enough. `null` keeps the key visible so the shape still + // reads correctly, rather than dropping the field silently. + return null + } + const resolved = ctx.resolve(ref) + if (!resolved) return null + + ctx.active.add(ref) + const value = generate(resolved, { ...ctx, depth: ctx.depth + 1 }) + ctx.active.delete(ref) + return value + } + + if (ctx.depth >= MAX_DEPTH) return null + + const next = { ...ctx, depth: ctx.depth + 1 } + + // allOf is composition — merge the pieces into one object. + if (Array.isArray(schema.allOf)) { + const merged: Record = {} + for (const part of schema.allOf) { + const value = generate(part, next) + if (isSchema(value)) Object.assign(merged, value) + } + return merged + } + + // oneOf/anyOf: no basis for choosing, so take the first and let the user edit. + const variants = schema.oneOf ?? schema.anyOf + if (Array.isArray(variants) && variants.length > 0) { + return generate(variants[0], next) + } + + if (Array.isArray(schema.type)) { + // OpenAPI 3.1 allows a type union, commonly ["string", "null"]. + const concrete = schema.type.find((t) => t !== 'null') ?? schema.type[0] + return generate({ ...schema, type: concrete }, ctx) + } + + switch (schema.type) { + case 'object': + case undefined: { + if (!isSchema(schema.properties)) { + // A free-form object, or `additionalProperties` only. + return schema.type === 'object' ? {} : null + } + const result: Record = {} + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (isSchema(propSchema) && propSchema.readOnly === true) continue + result[key] = generate(propSchema, next) + } + return result + } + case 'array': { + const item = generate(schema.items, next) + // One element is enough to show the shape. + return item === null && !isSchema(schema.items) ? [] : [item] + } + case 'string': + return exampleString(schema) + case 'integer': + return 0 + case 'number': + return 0 + case 'boolean': + return false + case 'null': + return null + default: + return null + } +} + +/** Look up a local `#/components/schemas/Name` pointer in the document. */ +export function makeRefResolver(document: unknown): (ref: string) => Schema | undefined { + return (ref: string) => { + if (!ref.startsWith('#/')) return undefined // external refs are not fetched + let node: unknown = document + for (const rawSegment of ref.slice(2).split('/')) { + // JSON Pointer escapes, per RFC 6901. + const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~') + if (!isSchema(node)) return undefined + node = node[segment] + } + return isSchema(node) ? node : undefined + } +} + +/** + * Render an example body for a media-type object, as formatted JSON. + * Returns an empty string when there is nothing useful to show. + */ +export function exampleBodyFor(mediaType: unknown, document: unknown): string { + if (!isSchema(mediaType)) return '' + + // A spec-provided example is always better than a generated one. + if (mediaType.example !== undefined) { + return JSON.stringify(mediaType.example, null, 2) + } + if (isSchema(mediaType.examples)) { + const first = Object.values(mediaType.examples)[0] + if (isSchema(first) && first.value !== undefined) { + return JSON.stringify(first.value, null, 2) + } + } + + if (!mediaType.schema) return '' + + try { + const value = generate(mediaType.schema, { + resolve: makeRefResolver(document), + active: new Set(), + depth: 0, + }) + if (value === null || value === undefined) return '' + return JSON.stringify(value, null, 2) + } catch { + // A malformed schema must not take the whole import down with it. + return '' + } +} + +/** + * Choose which media type to import a body for. + * + * JSON is preferred over whatever happens to be listed first — Swashbuckle + * commonly emits `application/json`, `application/xml` and a form variant for + * the same operation, and picking by position lands on XML often enough to be + * annoying. + */ +export function pickContentType(contentTypes: string[]): string | undefined { + if (contentTypes.length === 0) return undefined + return ( + contentTypes.find((type) => /^application\/(json|.*\+json)/i.test(type)) ?? + contentTypes[0] + ) +}