Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/components/BaseUrlVariableToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-lg border border-border/40 bg-muted/20 px-3 py-2.5 space-y-1.5">
<div className="flex items-center justify-between gap-3">
<Label className="text-sm text-foreground cursor-pointer">
Use a {`{{${DEFAULT_BASE_URL_VARIABLE}}}`} variable
</Label>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
data-testid="base-url-variable-toggle"
/>
</div>
<p className="text-[11px] text-muted-foreground/60 leading-snug">
{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."}
</p>
<code className="block text-[11px] font-mono text-muted-foreground/80 truncate">
{example}
</code>
</div>
)
}
37 changes: 13 additions & 24 deletions src/components/CollectionRunner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -83,7 +85,7 @@ export function CollectionRunner({ open, onOpenChange }: CollectionRunnerProps)
)

const runRequest = useCallback(
async (request: SavedRequest): Promise<RequestResult> => {
async (request: SavedRequest, collection?: Collection): Promise<RequestResult> => {
const startTime = performance.now()

try {
Expand All @@ -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
Expand Down Expand Up @@ -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])
}

Expand Down
51 changes: 44 additions & 7 deletions src/components/CollectionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -49,6 +50,8 @@ export const CollectionsPanel = forwardRef<HTMLDivElement, CollectionsPanelProps
importFromPostman,
} = useCollectionStore()

const { environments, activeEnvironmentId, setActiveEnvironment, setVariable } =
useEnvironmentStore()
const [expandedCollections, setExpandedCollections] = useState<Set<string>>(new Set())
const [openapiUrlModalOpen, setOpenapiUrlModalOpen] = useState(false)
const [openapiRawModalOpen, setOpenapiRawModalOpen] = useState(false)
Expand Down Expand Up @@ -91,15 +94,30 @@ export const CollectionsPanel = forwardRef<HTMLDivElement, CollectionsPanelProps
onOpenChange(false)
}

const handleSelectSavedRequest = (request: Parameters<typeof savedRequestToTab>[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)
}
Expand Down Expand Up @@ -192,20 +210,39 @@ export const CollectionsPanel = forwardRef<HTMLDivElement, CollectionsPanelProps
* host has no reason to send Access-Control-Allow-Origin for a desktop
* app's origin.
*/
const handleOpenapiImport = (apiDoc: unknown, baseUrl: string) => {
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) {
toast.error("No operations found in that document — is it an OpenAPI spec?");
return;
}

// Seed the variable so the collection works immediately, rather than
// importing 19 requests that all point at an undefined {{baseUrl}}.
let variableNote = "";
if (baseUrlVariable) {
if (activeEnvironmentId) {
setVariable(baseUrlVariable, baseUrl);
const envName = environments.find((env) => 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);
Expand Down
13 changes: 11 additions & 2 deletions src/components/OpenapiImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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")
Expand Down Expand Up @@ -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"
/>
<BaseUrlVariableToggle
checked={useVariable}
onCheckedChange={setUseVariable}
baseUrl={baseUrl}
/>
</div>
<DialogFooter className="mt-4 flex justify-end gap-2">
<Button
Expand Down
14 changes: 12 additions & 2 deletions src/components/OpenapiUrlImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Input } from "@/components/ui/input"
import { useState } from "react"
import { Loader2 } from "lucide-react"
import { fetchJsonViaBackend } from "@/utils/backendFetch"
import { BaseUrlVariableToggle } from "./BaseUrlVariableToggle"
import { DEFAULT_BASE_URL_VARIABLE } from "./openapiImportShared"

interface OpenapiDoc {
servers?: { url?: string }[]
Expand All @@ -13,7 +15,7 @@ interface OpenapiDoc {
interface OpenapiUrlImportModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onImport: (openapiDoc: unknown, baseUrl: string) => void
onImport: (openapiDoc: unknown, baseUrl: string, baseUrlVariable?: string) => void
}

export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiUrlImportModalProps) {
Expand All @@ -23,13 +25,15 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU
const [error, setError] = useState<string | null>(null)
const [detectedServers, setDetectedServers] = useState<string[]>([])
const [loadedDoc, setLoadedDoc] = useState<OpenapiDoc | null>(null)
const [useVariable, setUseVariable] = useState(true)

const reset = () => {
setOpenapiUrl("")
setBaseUrl("")
setDetectedServers([])
setLoadedDoc(null)
setError(null)
setUseVariable(true)
}

/**
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -160,6 +164,12 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU
</div>
)}
</div>
<BaseUrlVariableToggle
checked={useVariable}
onCheckedChange={setUseVariable}
baseUrl={baseUrl}
/>

{error && (
<p className="text-[12px] text-destructive bg-destructive/10 rounded px-2 py-1.5 break-all leading-snug">
⚠ {error}
Expand Down
12 changes: 10 additions & 2 deletions src/components/collections/CollectionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}

Expand Down Expand Up @@ -124,6 +125,13 @@ export function CollectionCard({
/>
)}

{isExpanded && (
<CollectionSettings
collection={collection}
onUpdateCollection={onUpdateCollection}
/>
)}

{isExpanded && (
<div className="space-y-1 mt-3 pl-4 border-l-2 border-border/20">
{collection.requests.length === 0 && (
Expand All @@ -138,7 +146,7 @@ export function CollectionCard({
>
<div
className="flex items-center gap-2.5 flex-1 cursor-pointer min-w-0"
onClick={() => onSelectRequest(request)}
onClick={() => onSelectRequest(request, collection)}
>
<span
className={cn(
Expand Down
Loading
Loading