Skip to content

Commit 2090a9d

Browse files
authored
Add swagger spec from URL and view spec (#410)
* Add swagger spec from URL and view spec Signed-off-by: Trevor Grant <[email protected]> * Add swagger spec from URL and view spec Signed-off-by: Trevor Grant <[email protected]> * Ranom type Signed-off-by: Trevor Grant <[email protected]> --------- Signed-off-by: Trevor Grant <[email protected]>
1 parent 59e4b19 commit 2090a9d

File tree

5 files changed

+191
-7
lines changed

5 files changed

+191
-7
lines changed

webapp/packages/api/user-service/main.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import traceback
1515
import httpx
1616
import firebase_admin
17+
import yaml
1718
from firebase_admin import credentials, auth
1819
from fastapi import Depends
1920
from fastapi.security import OAuth2PasswordBearer
@@ -138,6 +139,8 @@ class ClientLogPayload(BaseModel):
138139
level: str = "INFO"
139140
metadata: Optional[Dict[str, Any]] = None
140141

142+
class FetchSpecRequest(BaseModel):
143+
url: str
141144

142145
# Import models after defining local ones to avoid circular dependencies
143146
from models.chat import ChatResponse, ProviderConfig, SessionData
@@ -413,6 +416,36 @@ async def generate_agent_code(request: GenerateCodeRequest, user: dict = Depends
413416
code = await generate_code_function(request)
414417
return code
415418

419+
@app.post("/specs/fetch")
420+
async def fetch_spec_from_url(request: FetchSpecRequest, user: dict = Depends(get_current_user)):
421+
"""Fetches OpenAPI/Swagger spec content from a public URL."""
422+
async with httpx.AsyncClient() as client:
423+
try:
424+
response = await client.get(request.url)
425+
response.raise_for_status() # Raises an exception for 4xx/5xx responses
426+
427+
# Basic validation: Try to parse as JSON or YAML
428+
content = response.text
429+
try:
430+
json.loads(content)
431+
except json.JSONDecodeError:
432+
try:
433+
yaml.safe_load(content)
434+
except yaml.YAMLError:
435+
raise HTTPException(status_code=400, detail="Content from URL is not valid JSON or YAML.")
436+
437+
# Create a name from the URL path
438+
from urllib.parse import urlparse
439+
path = urlparse(str(request.url)).path
440+
name = path.split('/')[-1] if path else "spec_from_url.json"
441+
if not name:
442+
name = "spec_from_url.json"
443+
444+
return {"name": name, "content": content}
445+
except httpx.RequestError as e:
446+
raise HTTPException(status_code=400, detail=f"Error fetching from URL: {e}")
447+
448+
416449
@app.post("/agents/{agent_id}/deploy", status_code=201)
417450
async def deploy_agent(agent_id: str, db: DatabaseService = Depends(get_db), user: dict = Depends(get_current_user)):
418451
"""Registers an agent for internal REST deployment."""
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import {
4+
Dialog,
5+
DialogTitle,
6+
DialogContent,
7+
DialogActions,
8+
Button,
9+
Paper,
10+
} from '@mui/material';
11+
12+
const SpecViewerModal = ({ open, onClose, specName, specContent }) => {
13+
return (
14+
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
15+
<DialogTitle>Viewing Spec: {specName}</DialogTitle>
16+
<DialogContent>
17+
<Paper variant="outlined" sx={{ p: 2, mt: 1, backgroundColor: '#2e2e2e', maxHeight: '60vh', overflowY: 'auto' }}>
18+
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: '#e0e0e0' }}>
19+
{specContent}
20+
</pre>
21+
</Paper>
22+
</DialogContent>
23+
<DialogActions>
24+
<Button onClick={onClose}>Close</Button>
25+
</DialogActions>
26+
</Dialog>
27+
);
28+
};
29+
30+
SpecViewerModal.propTypes = {
31+
open: PropTypes.bool.isRequired,
32+
onClose: PropTypes.func.isRequired,
33+
specName: PropTypes.string,
34+
specContent: PropTypes.string,
35+
};
36+
37+
SpecViewerModal.defaultProps = {
38+
specName: '',
39+
specContent: '',
40+
};
41+
42+
export default SpecViewerModal;

webapp/packages/webui/src/pages/AgentCreationFlow/ToolsScreen.jsx

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
List,
99
ListItem,
1010
ListItemText,
11+
Divider,
1112
IconButton,
1213
Paper,
1314
Alert,
@@ -23,10 +24,13 @@ import {
2324
import DeleteIcon from '@mui/icons-material/Delete';
2425
import BuildIcon from '@mui/icons-material/Build';
2526
import UploadFileIcon from '@mui/icons-material/UploadFile';
27+
import VisibilityIcon from '@mui/icons-material/Visibility';
2628
import SmartToyIcon from '@mui/icons-material/SmartToy';
2729
import { useAgentFlow } from './AgentCreationFlowContext';
2830
import agentService from '../../services/agentService';
2931
import ToolsSelectionDialog from './ToolsSelectionDialog';
32+
import SpecViewerModal from '../../components/SpecViewerModal';
33+
3034

3135

3236
const ToolsScreen = () => {
@@ -36,6 +40,10 @@ const ToolsScreen = () => {
3640
const navigate = useNavigate();
3741
const [toolsDialog, setToolsDialog] = useState({ open: false, mcpUrl: '', existingSelectedTools: [] });
3842
const [tabIndex, setTabIndex] = useState(0);
43+
const [specUrl, setSpecUrl] = useState('');
44+
const [isFetchingSpec, setIsFetchingSpec] = useState(false);
45+
const [viewingSpec, setViewingSpec] = useState({ open: false, name: '', content: '' });
46+
3947

4048
// State for Gofannon Agents tab
4149
const [availableAgents, setAvailableAgents] = useState([]);
@@ -73,6 +81,28 @@ const ToolsScreen = () => {
7381
setError(null);
7482
};
7583

84+
const handleFetchSpec = async () => {
85+
if (!specUrl.trim()) {
86+
setError('Spec URL cannot be empty.');
87+
return;
88+
}
89+
setIsFetchingSpec(true);
90+
setError(null);
91+
try {
92+
const specData = await agentService.fetchSpecFromUrl(specUrl);
93+
if (swaggerSpecs.some(spec => spec.name === specData.name)) {
94+
setError(`A spec with the name "${specData.name}" already exists.`);
95+
} else {
96+
setSwaggerSpecs(prev => [...prev, specData]);
97+
setSpecUrl('');
98+
}
99+
} catch (err) {
100+
setError(err.message || 'Failed to fetch spec from URL.');
101+
} finally {
102+
setIsFetchingSpec(false);
103+
}
104+
};
105+
76106
const handleDeleteTool = (urlToDelete) => {
77107
setTools(prev => {
78108
const newTools = { ...prev };
@@ -82,6 +112,10 @@ const ToolsScreen = () => {
82112
setError(null);
83113
};
84114

115+
const handleViewSpec = (spec) => {
116+
setViewingSpec({ open: true, name: spec.name, content: spec.content });
117+
};
118+
85119
const handleFileChange = (event) => {
86120
const file = event.target.files[0];
87121
if (file) {
@@ -239,7 +273,25 @@ const ToolsScreen = () => {
239273
{tabIndex === 1 && (
240274
<Box>
241275
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
242-
Upload a Swagger or OpenAPI specification file (JSON or YAML). All endpoints will be exposed as tools.
276+
Upload a Swagger or OpenAPI specification file (JSON or YAML) or provide a URL.
277+
</Typography>
278+
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
279+
<TextField
280+
fullWidth
281+
label="Fetch Spec from URL"
282+
variant="outlined"
283+
value={specUrl}
284+
onChange={(e) => setSpecUrl(e.target.value)}
285+
disabled={isFetchingSpec}
286+
/>
287+
<Button variant="contained" onClick={handleFetchSpec} disabled={isFetchingSpec}>
288+
{isFetchingSpec ? <CircularProgress size={24} /> : 'Fetch'}
289+
</Button>
290+
</Box>
291+
<Divider sx={{ my: 2 }}>OR</Divider>
292+
293+
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
294+
{/* Upload a Swagger or OpenAPI specification file (JSON or YAML). All endpoints will be exposed as tools. */}
243295
</Typography>
244296
<Button
245297
variant="outlined"
@@ -248,7 +300,7 @@ const ToolsScreen = () => {
248300
startIcon={<UploadFileIcon />}
249301
sx={{ mb: 2 }}
250302
>
251-
Upload Spec File
303+
Upload Spec File From Disk
252304
<input type="file" hidden accept=".json,.yaml,.yml" onChange={handleFileChange} />
253305
</Button>
254306
{swaggerSpecs.length > 0 && (
@@ -257,9 +309,15 @@ const ToolsScreen = () => {
257309
<ListItem
258310
key={spec.name}
259311
secondaryAction={
260-
<IconButton edge="end" aria-label="delete" onClick={() => handleDeleteSpec(spec.name)}>
261-
<DeleteIcon />
262-
</IconButton>
312+
<>
313+
<IconButton edge="end" aria-label="view" onClick={() => handleViewSpec(spec)} sx={{ mr: 1 }}>
314+
<VisibilityIcon />
315+
</IconButton>
316+
<IconButton edge="end" aria-label="delete" onClick={() => handleDeleteSpec(spec.name)}>
317+
<DeleteIcon />
318+
</IconButton>
319+
</>
320+
263321
}
264322
>
265323
<ListItemText primary={spec.name} />
@@ -328,6 +386,12 @@ const ToolsScreen = () => {
328386
existingSelectedTools={toolsDialog.existingSelectedTools}
329387
onSaveSelectedTools={handleSaveSelectedTools}
330388
/>
389+
<SpecViewerModal
390+
open={viewingSpec.open}
391+
onClose={() => setViewingSpec({ open: false, name: '', content: '' })}
392+
specName={viewingSpec.name}
393+
specContent={viewingSpec.content}
394+
/>
331395
<Button
332396
variant="contained"
333397
color="primary"

webapp/packages/webui/src/pages/ViewAgent.jsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
DialogActions,
2222
DialogContent,
2323
DialogContentText,
24+
IconButton,
2425
DialogTitle,
2526
} from '@mui/material';
2627
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
@@ -30,12 +31,14 @@ import SaveIcon from '@mui/icons-material/Save';
3031
import WebIcon from '@mui/icons-material/Web';
3132
import ArticleIcon from '@mui/icons-material/Article';
3233
import SmartToyIcon from '@mui/icons-material/SmartToy';
34+
import VisibilityIcon from '@mui/icons-material/Visibility';
3335
import DeleteIcon from '@mui/icons-material/Delete';
3436
import Divider from '@mui/material/Divider';
3537

3638
import { useAgentFlow } from './AgentCreationFlow/AgentCreationFlowContext';
3739
import agentService from '../services/agentService';
3840
import CodeEditor from '../components/CodeEditor';
41+
import SpecViewerModal from '../components/SpecViewerModal';
3942

4043
const ViewAgent = () => {
4144
const { agentId } = useParams();
@@ -48,6 +51,7 @@ const ViewAgent = () => {
4851
const [isSaving, setIsSaving] = useState(false);
4952
const [saveSuccess, setSaveSuccess] = useState(false);
5053
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
54+
const [viewingSpec, setViewingSpec] = useState({ open: false, name: '', content: '' });
5155

5256
// State for deployment
5357
const [deployment, setDeployment] = useState(null);
@@ -129,6 +133,10 @@ const ViewAgent = () => {
129133
setAgent(prev => ({...prev, [field]: value}));
130134
};
131135

136+
const handleViewSpec = (spec) => {
137+
setViewingSpec({ open: true, name: spec.name, content: spec.content });
138+
};
139+
132140
const updateContextAndNavigate = (path) => {
133141
// Update the context with the current agent state before navigating
134142
agentFlowContext.setDescription(agent.description);
@@ -258,7 +266,14 @@ const ViewAgent = () => {
258266
{agent.swaggerSpecs.length > 0 ? (
259267
<List dense>
260268
{agent.swaggerSpecs.map(spec => (
261-
<ListItem key={spec.name}>
269+
<ListItem
270+
key={spec.name}
271+
secondaryAction={
272+
<IconButton edge="end" aria-label="view" onClick={() => handleViewSpec(spec)}>
273+
<VisibilityIcon />
274+
</IconButton>
275+
}
276+
>
262277
<ListItemIcon><ArticleIcon /></ListItemIcon>
263278
<ListItemText primary={spec.name} />
264279
</ListItem>
@@ -415,7 +430,13 @@ const ViewAgent = () => {
415430
Delete
416431
</Button>
417432
</DialogActions>
418-
</Dialog>
433+
</Dialog>
434+
<SpecViewerModal
435+
open={viewingSpec.open}
436+
onClose={() => setViewingSpec({ open: false, name: '', content: '' })}
437+
specName={viewingSpec.name}
438+
specContent={viewingSpec.content}
439+
/>
419440
</Paper>
420441
);
421442
};

webapp/packages/webui/src/services/agentService.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,29 @@ class AgentService {
4646
}
4747
}
4848

49+
async fetchSpecFromUrl(url) {
50+
try {
51+
const authHeaders = await this._getAuthHeaders();
52+
const response = await fetch(`${API_BASE_URL}/specs/fetch`, {
53+
method: 'POST',
54+
headers: {
55+
'Content-Type': 'application/json',
56+
'Accept': 'application/json',
57+
...authHeaders,
58+
},
59+
body: JSON.stringify({ url }),
60+
});
61+
62+
const data = await response.json();
63+
if (!response.ok) {
64+
throw new Error(data.detail || 'Failed to fetch spec from URL.');
65+
}
66+
return data; // returns { name: string, content: string }
67+
} catch (error) {
68+
console.error('[AgentService] Error fetching spec from URL:', error);
69+
throw error;
70+
}
71+
}
4972
async runCodeInSandbox(code, inputDict, tools, gofannonAgents) {
5073

5174
const requestBody = {
@@ -266,4 +289,5 @@ class AgentService {
266289
}
267290
}
268291

292+
269293
export default new AgentService();

0 commit comments

Comments
 (0)