From 59d88f43730b2620cc3bd03816a49c9b40c096d6 Mon Sep 17 00:00:00 2001 From: jt Date: Tue, 4 Aug 2026 22:06:31 -0700 Subject: [PATCH 1/2] Add collection-level auth, {{baseUrl}} imports, collection environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One collection could not be pointed at more than one environment, which is the normal case for anyone with dev/test/stage/prod. Three things stood in the way. Collection-level auth --------------------- Auth lived only on the individual request, and the OpenAPI import wrote `{ type: 'none' }` onto every one it produced. Authenticating an imported spec therefore meant configuring OAuth once per operation — dozens of times, for settings that are identical because they all sit behind the same API and the same app registration. Collections now carry auth, and requests fall back to it. `SavedRequest.authMode` records the choice explicitly, but it is optional, and resolveRequestAuth treats its absence as "keep doing exactly what you did before": a request with auth of its own keeps it, and only one with nothing to send inherits. Nothing already saved changes behaviour. Imported requests are written as `inherit`. Base URLs as a variable ----------------------- The import baked the absolute host into every request, welding the collection to whichever environment served the spec — and the environments where that hurts most are precisely the ones that do not expose a spec to import from, such as a public-facing prod with Swagger switched off. Imports now default to writing `{{baseUrl}}/pet/findByStatus` and seeding the variable into the active environment, so the same collection can be aimed anywhere by switching environments. The toggle explains the trade-off and can be turned off. Collections belong to an environment ------------------------------------ A collection written against `{{baseUrl}}` is meaningless without the environment that defines it, so `Collection.environmentId` is activated when a request from that collection is opened. Sending a dev request at prod because the wrong environment happened to be selected is a mistake worth designing out. Also here --------- The collection runner had its own copy of the auth-to-headers logic, which had drifted from the one in useRequest — including missing the case-insensitive header handling fixed in the previous commit, so a collection run could send two Authorization headers where a single request would not. Both now share utils/authHeaders. Verified in the running app: imported the Petstore spec into a `dev` environment, confirmed the requests read `{{baseUrl}}/pet`, `authMode` is `inherit`, and `baseUrl` was seeded as https://petstore3.swagger.io/api/v3; linked the collection to `dev` and confirmed it persisted. Co-Authored-By: Claude Opus 5 --- src/components/BaseUrlVariableToggle.tsx | 51 ++++++ src/components/CollectionRunner.tsx | 37 ++--- src/components/CollectionsPanel.tsx | 51 +++++- src/components/OpenapiImportModal.tsx | 13 +- src/components/OpenapiUrlImportModal.tsx | 14 +- src/components/collections/CollectionCard.tsx | 12 +- .../collections/CollectionSettings.tsx | 111 +++++++++++++ src/components/collections/collectionUtils.ts | 16 +- src/components/openapiImportShared.ts | 7 + src/hooks/useRequest.ts | 41 +---- src/test/OpenapiImport.test.tsx | 51 +++++- src/test/collectionAuth.test.ts | 157 ++++++++++++++++++ src/test/collectionEnvironment.test.tsx | 133 +++++++++++++++ src/types/index.ts | 13 ++ src/utils/authHeaders.ts | 70 ++++++++ src/utils/collection-converter.ts | 39 ++++- src/utils/collectionAuth.ts | 47 ++++++ 17 files changed, 778 insertions(+), 85 deletions(-) create mode 100644 src/components/BaseUrlVariableToggle.tsx create mode 100644 src/components/collections/CollectionSettings.tsx create mode 100644 src/components/openapiImportShared.ts create mode 100644 src/test/collectionAuth.test.ts create mode 100644 src/test/collectionEnvironment.test.tsx create mode 100644 src/utils/authHeaders.ts create mode 100644 src/utils/collectionAuth.ts 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 7b09690..392d7c5 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/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 6f46de0..d511b6a 100644 --- a/src/utils/collection-converter.ts +++ b/src/utils/collection-converter.ts @@ -215,7 +215,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[] = []; @@ -230,7 +245,17 @@ export function importFromOpenapi(openapiDoc: any, baseUrl: string): Collection[ updatedAt: new Date() }; - let serverUrl = baseUrl; + 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 +273,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,7 +294,7 @@ export function importFromOpenapi(openapiDoc: any, baseUrl: string): Collection[ enabled: true })); let contentType = "application/json"; - let body = ""; + const body = ""; if (operation.requestBody && operation.requestBody.content) { const contentTypes = Object.keys(operation.requestBody.content); if (contentTypes.length > 0) { @@ -285,6 +312,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 +} From 94f52ce554019c9b69a38c2b258f05ec70b33b9b Mon Sep 17 00:00:00 2001 From: jt Date: Tue, 4 Aug 2026 22:27:08 -0700 Subject: [PATCH 2/2] Generate request bodies from OpenAPI schemas on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imported requests arrived with an empty body even when the spec described exactly what the endpoint wanted, so every POST meant going back to the spec to find out what to type. `requestBody` was read only far enough to pick a content type; the schema behind it was ignored entirely. Bodies are now generated from the schema, resolving local `$ref`s against the document's components. The result is a shape rather than valid data — the point is to show the field names and types so they can be edited — but anything the spec states outright is preferred over an invented value: `example` first, then `examples`, then `default`, then the first `enum` member. `format` informs the placeholder, so a date-time reads as a date-time rather than "string". `readOnly` properties are omitted, since a request body must not send them. Recursive schemas are the thing that makes this non-trivial: a tree node with children of its own type, or Pet referring to Category referring back to Pet, are both entirely normal and both recurse forever if you follow refs naively. Refs currently being expanded on a branch are tracked and cut off with a null, which keeps the key visible so the shape still reads correctly, and there is a depth cap behind that. A malformed schema falls back to an empty body rather than taking the whole import down. Content-type selection now prefers JSON rather than taking whatever was listed first. Swashbuckle emits `application/json`, `application/xml` and a form variant for the same operation, and picking by position landed on XML often enough to matter. Bodies are only generated for JSON: the generator emits a JSON value, and sending that to an endpoint expecting XML would be worse than sending nothing. Co-Authored-By: Claude Opus 5 --- src/test/openapiExample.test.ts | 211 ++++++++++++++++++++++++++++++ src/utils/collection-converter.ts | 15 ++- src/utils/openapiExample.ts | 207 +++++++++++++++++++++++++++++ 3 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 src/test/openapiExample.test.ts create mode 100644 src/utils/openapiExample.ts 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/utils/collection-converter.ts b/src/utils/collection-converter.ts index d511b6a..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: { @@ -294,11 +295,17 @@ export function importFromOpenapi( 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 = { 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] + ) +}