Skip to content

Commit e43b853

Browse files
committed
Make UI and API routes extensible for external repositories
1 parent a89c7e8 commit e43b853

File tree

9 files changed

+442
-182
lines changed

9 files changed

+442
-182
lines changed

webapp/README.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,126 @@ This monorepo contains the complete scaffolding for the Gofannon web application
4242
- API: [http://localhost:8000/docs](http://localhost:8000/docs) (Swagger UI)
4343

4444
This setup uses a mock authentication provider, allowing you to bypass login and develop features directly. Storage is handled by a local MinIO container that simulates an S3-compatible service.
45+
46+
## Extending from an external repository
47+
48+
External repositories can copy the `webapp` directory and layer in new UI routes, cards, and API endpoints by updating configuration files instead of rewriting core code.
49+
50+
- **Web UI routes:** Add or override entries in `packages/webui/src/config/routes/overrides.js`. Each route can specify an `element`, whether it should render inside the shared `Layout`, whether it requires authentication, and any wrapper providers.
51+
- **Cards:** Register new cards via `packages/webui/src/extensions/index.js` (imported automatically on startup) and give them an ordering entry in `packages/webui/src/extensions/cards/config/defaultCardsConfig.js`.
52+
- **API endpoints:** Provide additional FastAPI registrars and list them with the `GOFANNON_API_ROUTE_REGISTRARS` environment variable or by editing `packages/api/user-service/config/routes_config.py`.
53+
54+
### Example: adding an "Echo" experience
55+
56+
Below is a full example of what an external repository would add after copying `webapp/` to introduce a new Echo card, page, and API endpoint. File paths are relative to the copied `webapp` directory.
57+
58+
1. **Add a new page at `packages/webui/src/pages/EchoPage.jsx`:**
59+
```jsx
60+
import React, { useState } from 'react';
61+
import { Box, Button, Stack, TextField, Typography } from '@mui/material';
62+
import apiClient from '../services/apiClient';
63+
64+
const EchoPage = () => {
65+
const [input, setInput] = useState('');
66+
const [result, setResult] = useState('');
67+
68+
const sendEcho = async () => {
69+
const response = await apiClient.post('/echo', { text: input });
70+
setResult(response.data.echo);
71+
};
72+
73+
return (
74+
<Stack spacing={3} maxWidth={520}>
75+
<Typography variant="h4">Echo</Typography>
76+
<TextField label="Message" value={input} onChange={(e) => setInput(e.target.value)} fullWidth />
77+
<Button variant="contained" onClick={sendEcho}>Send</Button>
78+
{result && (
79+
<Box p={2} bgcolor="grey.100" borderRadius={1}>
80+
<Typography variant="subtitle2">Response</Typography>
81+
<Typography>{result}</Typography>
82+
</Box>
83+
)}
84+
</Stack>
85+
);
86+
};
87+
88+
export default EchoPage;
89+
```
90+
91+
2. **Expose the route in `packages/webui/src/config/routes/overrides.js`:**
92+
```js
93+
import EchoPage from '../../pages/EchoPage';
94+
95+
const overrideRoutesConfig = {
96+
routes: [
97+
{
98+
id: 'echo',
99+
path: '/echo',
100+
element: <EchoPage />, // Renders inside the shared Layout + PrivateRoute by default
101+
},
102+
],
103+
};
104+
105+
export default overrideRoutesConfig;
106+
```
107+
108+
3. **Register a card in `packages/webui/src/extensions/cards/EchoCard.jsx` and wire it up in `packages/webui/src/extensions/index.js`:**
109+
```jsx
110+
// packages/webui/src/extensions/cards/EchoCard.jsx
111+
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
112+
113+
const EchoCard = {
114+
id: 'echo',
115+
title: 'Try Echo',
116+
description: 'Send a message and see it echoed back by the API.',
117+
buttonText: 'Open Echo',
118+
icon: <RecordVoiceOverIcon />,
119+
defaultOrder: 7,
120+
onAction: ({ navigate }) => navigate('/echo'),
121+
};
122+
123+
export default EchoCard;
124+
```
125+
126+
```js
127+
// packages/webui/src/extensions/index.js
128+
import EchoCard from './cards/EchoCard';
129+
import { registerExternalCardRegistrar } from './cards/cardRegistry';
130+
131+
registerExternalCardRegistrar(({ registerCard }) => registerCard(EchoCard));
132+
```
133+
134+
Also add the card to the ordering config (or set `CARD_CONFIG_OVERRIDES` in the environment):
135+
```js
136+
// packages/webui/src/extensions/cards/config/defaultCardsConfig.js
137+
const defaultCardsConfig = {
138+
cards: [
139+
// ...existing cards
140+
{ id: 'echo', order: 7, enabled: true },
141+
],
142+
};
143+
144+
export default defaultCardsConfig;
145+
```
146+
147+
4. **Add the API endpoint via a registrar (`packages/api/user-service/routes/echo.py`):**
148+
```python
149+
from fastapi import APIRouter
150+
151+
router = APIRouter()
152+
153+
@router.post("/echo")
154+
async def echo(payload: dict):
155+
return {"echo": payload.get("text", "")}
156+
157+
def register_echo_routes(app):
158+
app.include_router(router)
159+
```
160+
161+
Reference the registrar when launching the API:
162+
```bash
163+
export GOFANNON_API_ROUTE_REGISTRARS='["main:register_builtin_routes", "routes.echo:register_echo_routes"]'
164+
uvicorn main:app --reload
165+
```
166+
167+
With those files in place, running the normal build commands will surface the Echo card on the home screen, route users to the new `/echo` page, and round-trip their text through the `/echo` API endpoint.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Route configuration for FastAPI app.
2+
3+
This file defines how route registrars are located so that extension
4+
repositories can add new API endpoints by providing additional registrars
5+
without modifying the FastAPI initialization logic.
6+
"""
7+
import json
8+
import os
9+
from importlib import import_module
10+
from typing import Callable, Iterable, List
11+
12+
DEFAULT_ROUTE_REGISTRARS: List[str] = ["main:register_builtin_routes"]
13+
14+
15+
def _parse_registrar_list(raw_value) -> List[str]:
16+
if raw_value is None:
17+
return []
18+
19+
if isinstance(raw_value, str):
20+
try:
21+
parsed = json.loads(raw_value)
22+
if isinstance(parsed, list):
23+
return [entry for entry in parsed if isinstance(entry, str)]
24+
except json.JSONDecodeError:
25+
return []
26+
27+
if isinstance(raw_value, Iterable):
28+
return [entry for entry in raw_value if isinstance(entry, str)]
29+
30+
return []
31+
32+
33+
def _resolve_registrar(import_path: str) -> Callable:
34+
"""Load a registrar from a string like ``module:function``."""
35+
if ":" not in import_path:
36+
raise ValueError(f"Invalid registrar path '{import_path}'. Expected format module:function")
37+
38+
module_name, func_name = import_path.split(":", 1)
39+
module = import_module(module_name)
40+
registrar = getattr(module, func_name)
41+
if not callable(registrar):
42+
raise ValueError(f"Registrar '{import_path}' is not callable")
43+
return registrar
44+
45+
46+
def load_route_registrars() -> List[Callable]:
47+
env_override = _parse_registrar_list(os.getenv("GOFANNON_API_ROUTE_REGISTRARS"))
48+
configured_registrars = []
49+
50+
for registrar_path in [*DEFAULT_ROUTE_REGISTRARS, *env_override]:
51+
try:
52+
registrar_callable = _resolve_registrar(registrar_path)
53+
configured_registrars.append(registrar_callable)
54+
except Exception as error: # pragma: no cover - defensive logging only
55+
print(f"Failed to load route registrar '{registrar_path}': {error}")
56+
57+
return configured_registrars

0 commit comments

Comments
 (0)