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 (
+
+
+
+ Use a {`{{${DEFAULT_BASE_URL_VARIABLE}}}`} variable
+
+
+
+
+ {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"
/>
+
void
- onImport: (openapiDoc: unknown, baseUrl: string) => void
+ onImport: (openapiDoc: unknown, baseUrl: string, baseUrlVariable?: string) => void
}
export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiUrlImportModalProps) {
@@ -23,6 +25,7 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU
const [error, setError] = useState(null)
const [detectedServers, setDetectedServers] = useState([])
const [loadedDoc, setLoadedDoc] = useState(null)
+ const [useVariable, setUseVariable] = useState(true)
const reset = () => {
setOpenapiUrl("")
@@ -30,6 +33,7 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU
setDetectedServers([])
setLoadedDoc(null)
setError(null)
+ setUseVariable(true)
}
/**
@@ -83,7 +87,7 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU
}
try {
- onImport(apiDoc, baseUrl.trim())
+ onImport(apiDoc, baseUrl.trim(), useVariable ? DEFAULT_BASE_URL_VARIABLE : undefined)
reset()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to import OpenAPI specification")
@@ -160,6 +164,12 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU
)}
+
+
{error && (
⚠ {error}
diff --git a/src/components/collections/CollectionCard.tsx b/src/components/collections/CollectionCard.tsx
index 41f89bc..8415363 100644
--- a/src/components/collections/CollectionCard.tsx
+++ b/src/components/collections/CollectionCard.tsx
@@ -11,6 +11,7 @@ import { cn } from "@/lib/utils"
import { useThemeClass } from "@/hooks/useThemeClass"
import { Collection, SavedRequest, Tab } from "@/types"
import { methodColors } from "./collectionUtils"
+import { CollectionSettings } from "./CollectionSettings"
import {
ChevronDown,
ChevronRight,
@@ -29,7 +30,7 @@ interface CollectionCardProps {
onSaveCurrentRequest: (collectionId: string) => void
onRestoreAllRequests: (collection: Collection) => void
onDeleteCollection: (collectionId: string) => void
- onSelectRequest: (request: SavedRequest) => void
+ onSelectRequest: (request: SavedRequest, collection: Collection) => void
onDeleteRequest: (collectionId: string, requestId: string) => void
}
@@ -124,6 +125,13 @@ export function CollectionCard({
/>
)}
+ {isExpanded && (
+
+ )}
+
{isExpanded && (
{collection.requests.length === 0 && (
@@ -138,7 +146,7 @@ export function CollectionCard({
>
onSelectRequest(request)}
+ onClick={() => onSelectRequest(request, collection)}
>
) => void
+}
+
+/**
+ * Collection-wide auth and the environment this collection belongs to.
+ *
+ * Auth lives here because a spec import produces one request per operation, all
+ * behind the same API and the same OAuth app — configuring each one separately
+ * is busywork that scales with the size of the spec. Requests fall back to this
+ * unless they set their own; see resolveRequestAuth.
+ */
+export function CollectionSettings({ collection, onUpdateCollection }: CollectionSettingsProps) {
+ const themeClass = useThemeClass()
+ const { environments } = useEnvironmentStore()
+ const [showAuth, setShowAuth] = useState(false)
+
+ const authSummary = collection.auth && collection.auth.type !== "none"
+ ? collection.auth.type === "oauth2"
+ ? collection.auth.oauth2?.accessToken
+ ? "OAuth 2.0 · token held"
+ : "OAuth 2.0 · no token yet"
+ : collection.auth.type
+ : "None"
+
+ return (
+
+
+
+ Environment
+
+ onUpdateCollection(collection.id, {
+ environmentId: value === NO_ENVIRONMENT ? undefined : value,
+ })
+ }
+ >
+
+
+
+
+ None
+ {environments.map((env) => (
+
+ {env.name}
+
+ ))}
+
+
+
+ {collection.environmentId && (
+
+ Activated automatically when you open a request from this collection.
+
+ )}
+
+
setShowAuth((shown) => !shown)}
+ className="h-7 w-full justify-start px-0 hover:bg-transparent"
+ aria-label={`${showAuth ? "Hide" : "Show"} auth for ${collection.name}`}
+ >
+ {showAuth ? (
+
+ ) : (
+
+ )}
+
+ Collection auth
+
+ {authSummary}
+
+
+
+ {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]
+ )
+}