Skip to content

Commit ca7036d

Browse files
Implement server endpoints (#97)
This PR migrates the /server/* endpoints from the old restapi. It also enforces snake casing for server info and drops `/api` from the API prefix, keeping only the version.
1 parent 4cfd2a0 commit ca7036d

8 files changed

Lines changed: 311 additions & 130 deletions

File tree

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': '/v0',
26+
'VERSION': __version__,
27+
}

aiida_restapi/main.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,13 @@
11
"""Declaration of FastAPI application."""
22

33
import os
4-
import typing as t
54

6-
from fastapi import FastAPI, Request
7-
from fastapi.responses import HTMLResponse
5+
from fastapi import APIRouter, FastAPI
6+
from fastapi.responses import RedirectResponse
87

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

2812

2913
def create_app() -> FastAPI:
@@ -33,13 +17,25 @@ def create_app() -> FastAPI:
3317

3418
app = FastAPI()
3519

36-
for module in (auth, computers, daemon, groups, nodes, submit, users):
20+
api_router = APIRouter(prefix=API_CONFIG['PREFIX'])
21+
22+
for module in (auth, computers, daemon, groups, nodes, server, submit, users):
3723
if read_router := getattr(module, 'read_router', None):
38-
app.include_router(read_router)
24+
api_router.include_router(read_router)
3925
if not read_only and (write_router := getattr(module, 'write_router', None)):
40-
app.include_router(write_router)
26+
api_router.include_router(write_router)
27+
28+
api_router.add_route(
29+
f'{API_CONFIG["PREFIX"]}/graphql',
30+
main.app,
31+
methods=['POST'],
32+
)
33+
34+
api_router.add_route(
35+
'/',
36+
lambda _: RedirectResponse(url=api_router.url_path_for('endpoints')),
37+
)
4138

42-
app.add_route('/graphql', main.app)
43-
app.add_route('/', lambda request: generate_endpoints_table_endpoint(app)(request))
39+
app.include_router(api_router)
4440

4541
return app

aiida_restapi/routers/nodes.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from aiida_restapi.common.pagination import PaginatedResults
1818
from aiida_restapi.common.query import QueryParams, query_params
19+
from aiida_restapi.config import API_CONFIG
1920
from aiida_restapi.models.node import NodeModelRegistry
2021
from aiida_restapi.repository.node import NodeRepository
2122

@@ -146,13 +147,14 @@ async def get_node_types() -> list:
146147
>>> ...
147148
>>> ]
148149
"""
150+
api_prefix = API_CONFIG['PREFIX']
149151
return [
150152
{
151153
'label': model_registry.get_node_class_name(node_type),
152154
'node_type': node_type,
153-
'nodes': f'/nodes?filters={{"node_type":"{node_type}"}}',
154-
'projections': f'/nodes/projectable_properties?type={node_type}',
155-
'node_schema': f'/nodes/schema?type={node_type}',
155+
'nodes': f'{api_prefix}/nodes?filters={{"node_type":"{node_type}"}}',
156+
'projections': f'{api_prefix}/nodes/projectable_properties?type={node_type}',
157+
'node_schema': f'{api_prefix}/nodes/schema?type={node_type}',
156158
}
157159
for node_type in sorted(
158160
model_registry.get_node_types(), key=lambda node_type: model_registry.get_node_class_name(node_type)

aiida_restapi/routers/server.py

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

0 commit comments

Comments
 (0)