Skip to content

Commit 5258f86

Browse files
authored
Merge pull request #10 from LykosAI/collection-auth-and-environments
Add collection-level auth, {{baseUrl}} imports, collection environments
2 parents 977b683 + 94f52ce commit 5258f86

19 files changed

Lines changed: 1205 additions & 87 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Switch } from "@/components/ui/switch"
2+
import { Label } from "@/components/ui/label"
3+
import { DEFAULT_BASE_URL_VARIABLE } from "./openapiImportShared"
4+
5+
interface BaseUrlVariableToggleProps {
6+
checked: boolean
7+
onCheckedChange: (checked: boolean) => void
8+
/** The concrete base URL, shown so the trade-off is visible before importing. */
9+
baseUrl: string
10+
}
11+
12+
/**
13+
* Offers to write request URLs against a `{{baseUrl}}` variable rather than the
14+
* absolute host.
15+
*
16+
* Defaulted on, because the alternative welds the collection to whichever
17+
* environment happened to serve the spec — and the environments where that
18+
* hurts most are the ones that do not expose a spec to import from at all.
19+
*/
20+
export function BaseUrlVariableToggle({
21+
checked,
22+
onCheckedChange,
23+
baseUrl,
24+
}: BaseUrlVariableToggleProps) {
25+
const example = checked
26+
? `{{${DEFAULT_BASE_URL_VARIABLE}}}/pet/findByStatus`
27+
: `${(baseUrl || "https://api.example.com").replace(/\/+$/, "")}/pet/findByStatus`
28+
29+
return (
30+
<div className="rounded-lg border border-border/40 bg-muted/20 px-3 py-2.5 space-y-1.5">
31+
<div className="flex items-center justify-between gap-3">
32+
<Label className="text-sm text-foreground cursor-pointer">
33+
Use a {`{{${DEFAULT_BASE_URL_VARIABLE}}}`} variable
34+
</Label>
35+
<Switch
36+
checked={checked}
37+
onCheckedChange={onCheckedChange}
38+
data-testid="base-url-variable-toggle"
39+
/>
40+
</div>
41+
<p className="text-[11px] text-muted-foreground/60 leading-snug">
42+
{checked
43+
? "Requests point at the variable, so one collection can be aimed at dev, test, stage or prod by switching environments."
44+
: "Requests hard-code this host. The collection will only work against the environment you imported from."}
45+
</p>
46+
<code className="block text-[11px] font-mono text-muted-foreground/80 truncate">
47+
{example}
48+
</code>
49+
</div>
50+
)
51+
}

src/components/CollectionRunner.tsx

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { Badge } from "@/components/ui/badge"
44
import { Progress } from "@/components/ui/progress"
55
import { ScrollArea } from "@/components/ui/scroll-area"
66
import { Card } from "@/components/ui/card"
7-
import { SavedRequest, Response, TestResult } from "@/types"
7+
import { Collection, SavedRequest, Response, TestResult } from "@/types"
8+
import { applyAuthToHeaders } from "@/utils/authHeaders"
9+
import { resolveRequestAuth } from "@/utils/collectionAuth"
810
import { useCollectionStore } from "@/store/collections"
911
import { useEnvironmentStore } from "@/store/environments"
1012
import { useSettingsStore } from "@/store/settings"
@@ -83,7 +85,7 @@ export function CollectionRunner({ open, onOpenChange }: CollectionRunnerProps)
8385
)
8486

8587
const runRequest = useCallback(
86-
async (request: SavedRequest): Promise<RequestResult> => {
88+
async (request: SavedRequest, collection?: Collection): Promise<RequestResult> => {
8789
const startTime = performance.now()
8890

8991
try {
@@ -95,28 +97,15 @@ export function CollectionRunner({ open, onOpenChange }: CollectionRunnerProps)
9597
}
9698
})
9799

98-
// Apply auth
100+
// Apply auth, falling back to the collection's where the request
101+
// does not carry its own — an imported spec relies on that.
99102
let url = substituteVariables(request.rawUrl || request.url)
100-
if (request.auth.type === 'basic') {
101-
const username = substituteVariables(request.auth.username || '')
102-
const password = substituteVariables(request.auth.password || '')
103-
const credentials = btoa(`${username}:${password}`)
104-
headerRecord['Authorization'] = `Basic ${credentials}`
105-
} else if (request.auth.type === 'bearer' && request.auth.token) {
106-
headerRecord['Authorization'] = `Bearer ${substituteVariables(request.auth.token)}`
107-
} else if (request.auth.type === 'api-key' && request.auth.key && request.auth.value) {
108-
const key = substituteVariables(request.auth.key)
109-
const value = substituteVariables(request.auth.value)
110-
if (request.auth.addTo === 'header') {
111-
headerRecord[key] = value
112-
} else {
113-
const separator = url.includes('?') ? '&' : '?'
114-
url += `${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`
115-
}
116-
} else if (request.auth.type === 'oauth2' && request.auth.oauth2?.accessToken) {
117-
const tokenType = request.auth.oauth2.tokenType || 'Bearer'
118-
headerRecord['Authorization'] = `${tokenType} ${substituteVariables(request.auth.oauth2.accessToken)}`
119-
}
103+
url = applyAuthToHeaders(
104+
resolveRequestAuth(request, collection),
105+
headerRecord,
106+
url,
107+
substituteVariables
108+
)
120109

121110
// Cookie header
122111
const cookieHeader = request.cookies
@@ -275,7 +264,7 @@ export function CollectionRunner({ open, onOpenChange }: CollectionRunnerProps)
275264
if (cancelRef.current) break
276265

277266
setCurrentIndex(i + 1)
278-
const result = await runRequest(selectedCollection.requests[i])
267+
const result = await runRequest(selectedCollection.requests[i], selectedCollection)
279268
setResults((prev) => [...prev, result])
280269
}
281270

src/components/CollectionsPanel.tsx

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { Button } from "@/components/ui/button"
99
import { ScrollArea } from "@/components/ui/scroll-area"
1010
import { FolderPlus, Download, Upload } from "lucide-react"
1111
import { useCollectionStore } from "@/store/collections"
12-
import { Tab } from "@/types"
12+
import { Collection, SavedRequest, Tab } from "@/types"
13+
import { useEnvironmentStore } from "@/store/environments"
1314
import { getRequestNameFromUrl } from "@/utils/url"
1415
import {
1516
DropdownMenu,
@@ -49,6 +50,8 @@ export const CollectionsPanel = forwardRef<HTMLDivElement, CollectionsPanelProps
4950
importFromPostman,
5051
} = useCollectionStore()
5152

53+
const { environments, activeEnvironmentId, setActiveEnvironment, setVariable } =
54+
useEnvironmentStore()
5255
const [expandedCollections, setExpandedCollections] = useState<Set<string>>(new Set())
5356
const [openapiUrlModalOpen, setOpenapiUrlModalOpen] = useState(false)
5457
const [openapiRawModalOpen, setOpenapiRawModalOpen] = useState(false)
@@ -91,15 +94,30 @@ export const CollectionsPanel = forwardRef<HTMLDivElement, CollectionsPanelProps
9194
onOpenChange(false)
9295
}
9396

94-
const handleSelectSavedRequest = (request: Parameters<typeof savedRequestToTab>[0]) => {
95-
handleSelectRequest(savedRequestToTab(request))
97+
/**
98+
* Switch to the collection's environment before opening anything from it.
99+
* A collection written against `{{baseUrl}}` is meaningless without the
100+
* environment that defines it, and silently sending a dev request at prod
101+
* (or the reverse) is exactly the mistake worth designing out.
102+
*/
103+
const activateCollectionEnvironment = (collection?: Collection) => {
104+
if (!collection?.environmentId) return
105+
if (collection.environmentId === activeEnvironmentId) return
106+
if (!environments.some((env) => env.id === collection.environmentId)) return
107+
setActiveEnvironment(collection.environmentId)
108+
}
109+
110+
const handleSelectSavedRequest = (request: SavedRequest, collection?: Collection) => {
111+
activateCollectionEnvironment(collection)
112+
handleSelectRequest(savedRequestToTab(request, collection))
96113
}
97114

98115
const handleRestoreAllRequests = (collectionId: string) => {
99116
const targetCollection = collections.find((collection) => collection.id === collectionId)
100117
if (!targetCollection) return
118+
activateCollectionEnvironment(targetCollection)
101119
targetCollection.requests.forEach((request) => {
102-
onRequestSelect(savedRequestToTab(request))
120+
onRequestSelect(savedRequestToTab(request, targetCollection))
103121
})
104122
onOpenChange(false)
105123
}
@@ -192,20 +210,39 @@ export const CollectionsPanel = forwardRef<HTMLDivElement, CollectionsPanelProps
192210
* host has no reason to send Access-Control-Allow-Origin for a desktop
193211
* app's origin.
194212
*/
195-
const handleOpenapiImport = (apiDoc: unknown, baseUrl: string) => {
213+
const handleOpenapiImport = (
214+
apiDoc: unknown,
215+
baseUrl: string,
216+
baseUrlVariable?: string
217+
) => {
196218
try {
197-
const importedCollections = importFromOpenapi(apiDoc, baseUrl);
219+
const importedCollections = importFromOpenapi(apiDoc, baseUrl, { baseUrlVariable });
198220
const requestCount = importedCollections.reduce((sum, c) => sum + c.requests.length, 0);
199221

200222
if (requestCount === 0) {
201223
toast.error("No operations found in that document — is it an OpenAPI spec?");
202224
return;
203225
}
204226

227+
// Seed the variable so the collection works immediately, rather than
228+
// importing 19 requests that all point at an undefined {{baseUrl}}.
229+
let variableNote = "";
230+
if (baseUrlVariable) {
231+
if (activeEnvironmentId) {
232+
setVariable(baseUrlVariable, baseUrl);
233+
const envName = environments.find((env) => env.id === activeEnvironmentId)?.name
234+
variableNote = ` — {{${baseUrlVariable}}} set${envName ? ` in ${envName}` : ""}`;
235+
} else {
236+
variableNote = ` — set {{${baseUrlVariable}}} in an environment to use it`;
237+
}
238+
}
239+
205240
importCollections(importedCollections);
206241
setOpenapiUrlModalOpen(false);
207242
setOpenapiRawModalOpen(false);
208-
toast.success(`Imported ${requestCount} request${requestCount === 1 ? "" : "s"} from OpenAPI`);
243+
toast.success(
244+
`Imported ${requestCount} request${requestCount === 1 ? "" : "s"} from OpenAPI${variableNote}`
245+
);
209246
} catch (error) {
210247
if (shouldLogImportErrors) {
211248
console.error("Error importing OpenAPI:", error);

src/components/OpenapiImportModal.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@ import { Input } from "@/components/ui/input"
44
import { Textarea } from "@/components/ui/textarea"
55
import { useState } from "react"
66
import { toast } from "sonner"
7+
import { BaseUrlVariableToggle } from "./BaseUrlVariableToggle"
8+
import { DEFAULT_BASE_URL_VARIABLE } from "./openapiImportShared"
79

810
interface OpenapiImportModalProps {
911
open: boolean
1012
onOpenChange: (open: boolean) => void
11-
onImport: (openapiDoc: unknown, baseUrl: string) => void
13+
onImport: (openapiDoc: unknown, baseUrl: string, baseUrlVariable?: string) => void
1214
}
1315

1416
export function OpenapiImportModal({ open, onOpenChange, onImport }: OpenapiImportModalProps) {
1517
const [rawJSON, setRawJSON] = useState("")
1618
const [baseUrl, setBaseUrl] = useState("")
19+
const [useVariable, setUseVariable] = useState(true)
1720

1821
const handleImport = () => {
1922
if (!rawJSON.trim()) {
@@ -35,9 +38,10 @@ export function OpenapiImportModal({ open, onOpenChange, onImport }: OpenapiImpo
3538
}
3639

3740
try {
38-
onImport(apiDoc, baseUrl)
41+
onImport(apiDoc, baseUrl, useVariable ? DEFAULT_BASE_URL_VARIABLE : undefined)
3942
setRawJSON("")
4043
setBaseUrl("")
44+
setUseVariable(true)
4145
} catch (error) {
4246
console.error("Error importing OpenAPI:", error)
4347
toast.error(error instanceof Error ? error.message : "Failed to import OpenAPI specification")
@@ -66,6 +70,11 @@ export function OpenapiImportModal({ open, onOpenChange, onImport }: OpenapiImpo
6670
onChange={(e) => setBaseUrl(e.target.value)}
6771
className="bg-background text-foreground border-border placeholder:text-muted-foreground"
6872
/>
73+
<BaseUrlVariableToggle
74+
checked={useVariable}
75+
onCheckedChange={setUseVariable}
76+
baseUrl={baseUrl}
77+
/>
6978
</div>
7079
<DialogFooter className="mt-4 flex justify-end gap-2">
7180
<Button

src/components/OpenapiUrlImportModal.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Input } from "@/components/ui/input"
44
import { useState } from "react"
55
import { Loader2 } from "lucide-react"
66
import { fetchJsonViaBackend } from "@/utils/backendFetch"
7+
import { BaseUrlVariableToggle } from "./BaseUrlVariableToggle"
8+
import { DEFAULT_BASE_URL_VARIABLE } from "./openapiImportShared"
79

810
interface OpenapiDoc {
911
servers?: { url?: string }[]
@@ -13,7 +15,7 @@ interface OpenapiDoc {
1315
interface OpenapiUrlImportModalProps {
1416
open: boolean
1517
onOpenChange: (open: boolean) => void
16-
onImport: (openapiDoc: unknown, baseUrl: string) => void
18+
onImport: (openapiDoc: unknown, baseUrl: string, baseUrlVariable?: string) => void
1719
}
1820

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

2730
const reset = () => {
2831
setOpenapiUrl("")
2932
setBaseUrl("")
3033
setDetectedServers([])
3134
setLoadedDoc(null)
3235
setError(null)
36+
setUseVariable(true)
3337
}
3438

3539
/**
@@ -83,7 +87,7 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU
8387
}
8488

8589
try {
86-
onImport(apiDoc, baseUrl.trim())
90+
onImport(apiDoc, baseUrl.trim(), useVariable ? DEFAULT_BASE_URL_VARIABLE : undefined)
8791
reset()
8892
} catch (err) {
8993
setError(err instanceof Error ? err.message : "Failed to import OpenAPI specification")
@@ -160,6 +164,12 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU
160164
</div>
161165
)}
162166
</div>
167+
<BaseUrlVariableToggle
168+
checked={useVariable}
169+
onCheckedChange={setUseVariable}
170+
baseUrl={baseUrl}
171+
/>
172+
163173
{error && (
164174
<p className="text-[12px] text-destructive bg-destructive/10 rounded px-2 py-1.5 break-all leading-snug">
165175
{error}

src/components/collections/CollectionCard.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { cn } from "@/lib/utils"
1111
import { useThemeClass } from "@/hooks/useThemeClass"
1212
import { Collection, SavedRequest, Tab } from "@/types"
1313
import { methodColors } from "./collectionUtils"
14+
import { CollectionSettings } from "./CollectionSettings"
1415
import {
1516
ChevronDown,
1617
ChevronRight,
@@ -29,7 +30,7 @@ interface CollectionCardProps {
2930
onSaveCurrentRequest: (collectionId: string) => void
3031
onRestoreAllRequests: (collection: Collection) => void
3132
onDeleteCollection: (collectionId: string) => void
32-
onSelectRequest: (request: SavedRequest) => void
33+
onSelectRequest: (request: SavedRequest, collection: Collection) => void
3334
onDeleteRequest: (collectionId: string, requestId: string) => void
3435
}
3536

@@ -124,6 +125,13 @@ export function CollectionCard({
124125
/>
125126
)}
126127

128+
{isExpanded && (
129+
<CollectionSettings
130+
collection={collection}
131+
onUpdateCollection={onUpdateCollection}
132+
/>
133+
)}
134+
127135
{isExpanded && (
128136
<div className="space-y-1 mt-3 pl-4 border-l-2 border-border/20">
129137
{collection.requests.length === 0 && (
@@ -138,7 +146,7 @@ export function CollectionCard({
138146
>
139147
<div
140148
className="flex items-center gap-2.5 flex-1 cursor-pointer min-w-0"
141-
onClick={() => onSelectRequest(request)}
149+
onClick={() => onSelectRequest(request, collection)}
142150
>
143151
<span
144152
className={cn(

0 commit comments

Comments
 (0)