Skip to content

Commit 9f2c720

Browse files
Implement server router
1 parent 6943d85 commit 9f2c720

File tree

7 files changed

+305
-129
lines changed

7 files changed

+305
-129
lines changed

aiida_restapi/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Configuration of API"""
22

3+
from aiida_restapi import __version__
4+
35
# to get a string like this run:
46
# openssl rand -hex 32
57
SECRET_KEY = '09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7'
@@ -18,3 +20,8 @@
1820
'disabled': False,
1921
}
2022
}
23+
24+
API_CONFIG = {
25+
'PREFIX': '/api/v0',
26+
'VERSION': __version__,
27+
}

aiida_restapi/main.py

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,38 @@
11
"""Declaration of FastAPI application."""
22

3-
import typing as t
4-
5-
from fastapi import FastAPI, Request
6-
from fastapi.responses import HTMLResponse
3+
from fastapi import APIRouter, FastAPI
4+
from fastapi.responses import RedirectResponse
75

6+
from aiida_restapi.config import API_CONFIG
87
from aiida_restapi.graphql import main
9-
from aiida_restapi.routers import auth, computers, daemon, groups, nodes, submit, users
10-
from aiida_restapi.utils import generate_endpoints_table
11-
12-
13-
def generate_endpoints_table_endpoint(app: FastAPI) -> t.Callable[[Request], HTMLResponse]:
14-
"""Generate an endpoint that lists all registered API routes."""
15-
16-
def list_endpoints(request: Request) -> HTMLResponse:
17-
"""Return an HTML table of all registered API routes."""
18-
return HTMLResponse(
19-
content=generate_endpoints_table(
20-
str(request.base_url).rstrip('/'),
21-
app.routes,
22-
),
23-
)
24-
25-
return list_endpoints
8+
from aiida_restapi.routers import auth, computers, daemon, groups, nodes, server, submit, users
269

2710

2811
def create_app(read_only: bool = False) -> FastAPI:
2912
"""Create the FastAPI application and include the routers."""
3013
app = FastAPI()
31-
app.include_router(auth.router)
3214

33-
for module in (computers, daemon, groups, nodes, submit, users):
15+
api_router = APIRouter(prefix=API_CONFIG['PREFIX'])
16+
17+
api_router.include_router(auth.router)
18+
19+
for module in (computers, daemon, groups, nodes, server, submit, users):
3420
if read_router := getattr(module, 'read_router', None):
35-
app.include_router(read_router)
21+
api_router.include_router(read_router)
3622
if not read_only and (write_router := getattr(module, 'write_router', None)):
37-
app.include_router(write_router)
23+
api_router.include_router(write_router)
24+
25+
api_router.add_route(
26+
'/graphql',
27+
main.app,
28+
methods=['POST'],
29+
)
30+
31+
api_router.add_route(
32+
'/',
33+
lambda _: RedirectResponse(url=api_router.url_path_for('endpoints')),
34+
)
3835

39-
app.add_route('/graphql', main.app)
40-
app.add_route('/', lambda request: generate_endpoints_table_endpoint(app)(request))
36+
app.include_router(api_router)
4137

4238
return app

aiida_restapi/routers/server.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Declaration of FastAPI application."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
import pydantic as pdt
8+
from aiida import __version__ as aiida_version
9+
from fastapi import APIRouter, Request
10+
from fastapi.responses import HTMLResponse
11+
from fastapi.routing import APIRoute
12+
from starlette.routing import Route
13+
14+
from aiida_restapi.config import API_CONFIG
15+
16+
read_router = APIRouter()
17+
18+
19+
class ServerInfo(pdt.BaseModel):
20+
"""API version information."""
21+
22+
API_major_version: str = pdt.Field(description='Major version of the API')
23+
API_minor_version: str = pdt.Field(description='Minor version of the API')
24+
API_revision_version: str = pdt.Field(description='Revision version of the API')
25+
API_prefix: str = pdt.Field(description='Prefix for all API endpoints')
26+
AiiDA_version: str = pdt.Field(description='Version of the AiiDA installation')
27+
28+
29+
@read_router.get('/server/info', response_model=ServerInfo)
30+
async def get_server_info() -> ServerInfo:
31+
"""Get the API version information."""
32+
api_version = API_CONFIG['VERSION'].split('.')
33+
return ServerInfo(
34+
API_major_version=api_version[0],
35+
API_minor_version=api_version[1],
36+
API_revision_version=api_version[2],
37+
API_prefix=API_CONFIG['PREFIX'],
38+
AiiDA_version=aiida_version,
39+
)
40+
41+
42+
class ServerEndpoint(pdt.BaseModel):
43+
"""API endpoint."""
44+
45+
path: str = pdt.Field(description='Path of the endpoint')
46+
group: str | None = pdt.Field(description='Group of the endpoint')
47+
methods: set[str] = pdt.Field(description='HTTP methods supported by the endpoint')
48+
description: str = pdt.Field('-', description='Description of the endpoint')
49+
50+
51+
@read_router.get(
52+
'/server/endpoints',
53+
name='endpoints',
54+
response_model=dict[str, list[ServerEndpoint]],
55+
)
56+
async def get_server_endpoints(request: Request) -> dict[str, list[ServerEndpoint]]:
57+
"""Get a JSON-serializable dictionary of all registered API routes.
58+
59+
:param request: The FastAPI request object.
60+
:return: A JSON-serializable dictionary of all registered API routes.
61+
"""
62+
endpoints: list[ServerEndpoint] = []
63+
64+
for route in request.app.routes:
65+
if route.path == '/':
66+
continue
67+
68+
group, methods, description = _get_route_parts(route)
69+
base_url = str(request.base_url).rstrip('/')
70+
71+
endpoint = {
72+
'path': base_url + route.path,
73+
'group': group,
74+
'methods': methods,
75+
'description': description,
76+
}
77+
78+
endpoints.append(ServerEndpoint(**endpoint))
79+
80+
return {'endpoints': endpoints}
81+
82+
83+
@read_router.get(
84+
'/server/endpoints/table',
85+
response_class=HTMLResponse,
86+
)
87+
async def get_server_endpoints_table(request: Request) -> HTMLResponse:
88+
"""Get an HTML table of all registered API routes.
89+
90+
:param request: The FastAPI request object.
91+
:return: An HTML table of all registered API routes.
92+
"""
93+
routes = request.app.routes
94+
base_url = str(request.base_url).rstrip('/')
95+
96+
rows = []
97+
98+
for route in routes:
99+
if route.path == '/':
100+
continue
101+
102+
path = base_url + route.path
103+
group, methods, description = _get_route_parts(route)
104+
105+
disable_url = (
106+
(
107+
isinstance(route, APIRoute)
108+
and any(
109+
param
110+
for param in route.dependant.path_params
111+
+ route.dependant.query_params
112+
+ route.dependant.body_params
113+
if param.required
114+
)
115+
)
116+
or (route.methods and 'POST' in route.methods)
117+
or 'auth' in path
118+
)
119+
120+
path_row = path if disable_url else f'<a href="{path}">{path}</a>'
121+
122+
rows.append(f"""
123+
<tr>
124+
<td>{path_row}</td>
125+
<td>{group or '-'}</td>
126+
<td>{', '.join(methods)}</td>
127+
<td>{description or '-'}</td>
128+
</tr>
129+
""")
130+
131+
return HTMLResponse(
132+
content=f"""
133+
<html>
134+
<head>
135+
<title>AiiDA REST API Endpoints</title>
136+
<style>
137+
body {{
138+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
139+
padding: 1em;
140+
color: #222;
141+
}}
142+
h1 {{
143+
margin-bottom: 0.5em;
144+
}}
145+
table {{
146+
border-collapse: collapse;
147+
width: 100%;
148+
}}
149+
th, td {{
150+
border: 1px solid #ddd;
151+
padding: 0.5em 0.75em;
152+
text-align: left;
153+
}}
154+
th {{
155+
background-color: #f4f4f4;
156+
}}
157+
tr:nth-child(even) {{
158+
background-color: #fafafa;
159+
}}
160+
tr:hover {{
161+
background-color: #f1f1f1;
162+
}}
163+
a {{
164+
text-decoration: none;
165+
color: #0066cc;
166+
}}
167+
a:hover {{
168+
text-decoration: underline;
169+
}}
170+
</style>
171+
</head>
172+
<body>
173+
<h1>AiiDA REST API Endpoints</h1>
174+
<table>
175+
<thead>
176+
<tr>
177+
<th>URL</th>
178+
<th>Group</th>
179+
<th>Methods</th>
180+
<th>Description</th>
181+
</tr>
182+
</thead>
183+
<tbody>
184+
{''.join(rows)}
185+
</tbody>
186+
</table>
187+
</body>
188+
</html>
189+
"""
190+
)
191+
192+
193+
def _get_route_parts(route: Route) -> tuple[str | None, set[str], str]:
194+
"""Return the parts of a route: path, group, methods, description.
195+
196+
:param route: A FastAPI/Starlette Route object.
197+
:return: A tuple containing the group, methods, and description of the route.
198+
"""
199+
prefix = re.escape(API_CONFIG['PREFIX'])
200+
match = re.match(rf'^{prefix}/([^/]+)/?.*', route.path)
201+
group = match.group(1) if match else None
202+
methods = (route.methods or set()) - {'HEAD', 'OPTIONS'}
203+
description = (route.endpoint.__doc__ or '').split('\n')[0].strip()
204+
return group, methods, description

aiida_restapi/utils.py

Lines changed: 0 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -3,107 +3,8 @@
33
import datetime
44

55
from dateutil.parser import parser as date_parser
6-
from fastapi.routing import APIRoute
7-
from starlette.routing import Route
86

97

108
def parse_date(string: str) -> datetime.datetime:
119
"""Parse any date/time stamp string."""
1210
return date_parser().parse(string)
13-
14-
15-
def generate_endpoints_table(base_url: str, routes: list[Route]) -> str:
16-
"""Return an HTML table string of all registered API routes.
17-
18-
:param base_url: The base URL to prepend to each route path.
19-
:param routes: A list of FastAPI/Starlette Route objects.
20-
:return: An HTML string representing the table of endpoints.
21-
"""
22-
rows = []
23-
24-
for route in routes:
25-
if route.path == '/':
26-
continue
27-
28-
path = f'{base_url}{route.path}'
29-
methods = ', '.join(sorted((route.methods - {'HEAD', 'OPTIONS'}) if route.methods else {}))
30-
summary = 'Post graphQL query' if 'graphql' in path else (route.endpoint.__doc__ or '').split('\n')[0].strip()
31-
32-
disable_url = (
33-
(
34-
isinstance(route, APIRoute)
35-
and any(
36-
param
37-
for param in route.dependant.path_params
38-
+ route.dependant.query_params
39-
+ route.dependant.body_params
40-
if param.required
41-
)
42-
)
43-
or (route.methods and 'POST' in route.methods)
44-
or 'auth' in path
45-
)
46-
47-
path_row = path if disable_url else f'<a href="{path}">{path}</a>'
48-
49-
rows.append(f"""
50-
<tr>
51-
<td>{path_row}</td>
52-
<td>{methods}</td>
53-
<td>{summary or '-'}</td>
54-
</tr>
55-
""")
56-
57-
return f"""
58-
<html>
59-
<head>
60-
<title>AiiDA REST API Endpoints</title>
61-
<style>
62-
body {{
63-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
64-
padding: 1em;
65-
color: #222;
66-
}}
67-
h1 {{
68-
margin-bottom: 0.5em;
69-
}}
70-
table {{
71-
border-collapse: collapse;
72-
width: 100%;
73-
}}
74-
th, td {{
75-
border: 1px solid #ddd;
76-
padding: 0.5em 0.75em;
77-
text-align: left;
78-
}}
79-
th {{
80-
background-color: #f4f4f4;
81-
}}
82-
tr:nth-child(even) {{
83-
background-color: #fafafa;
84-
}}
85-
tr:hover {{
86-
background-color: #f1f1f1;
87-
}}
88-
a {{
89-
text-decoration: none;
90-
color: #0066cc;
91-
}}
92-
a:hover {{
93-
text-decoration: underline;
94-
}}
95-
</style>
96-
</head>
97-
<body>
98-
<h1>AiiDA REST API Endpoints</h1>
99-
<table>
100-
<tr>
101-
<th>URL</th>
102-
<th>Methods</th>
103-
<th>Summary</th>
104-
</tr>
105-
{''.join(rows)}
106-
</table>
107-
</body>
108-
</html>
109-
"""

0 commit comments

Comments
 (0)