squirrels 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of squirrels might be problematic. Click here for more details.
- dateutils/__init__.py +6 -0
- dateutils/_enums.py +25 -0
- squirrels/dateutils.py → dateutils/_implementation.py +58 -111
- dateutils/types.py +6 -0
- squirrels/__init__.py +13 -11
- squirrels/_api_routes/__init__.py +5 -0
- squirrels/_api_routes/auth.py +271 -0
- squirrels/_api_routes/base.py +165 -0
- squirrels/_api_routes/dashboards.py +150 -0
- squirrels/_api_routes/data_management.py +145 -0
- squirrels/_api_routes/datasets.py +257 -0
- squirrels/_api_routes/oauth2.py +298 -0
- squirrels/_api_routes/project.py +252 -0
- squirrels/_api_server.py +256 -450
- squirrels/_arguments/__init__.py +0 -0
- squirrels/_arguments/init_time_args.py +108 -0
- squirrels/_arguments/run_time_args.py +147 -0
- squirrels/_auth.py +960 -0
- squirrels/_command_line.py +126 -45
- squirrels/_compile_prompts.py +147 -0
- squirrels/_connection_set.py +48 -26
- squirrels/_constants.py +68 -38
- squirrels/_dashboards.py +160 -0
- squirrels/_data_sources.py +570 -0
- squirrels/_dataset_types.py +84 -0
- squirrels/_exceptions.py +29 -0
- squirrels/_initializer.py +177 -80
- squirrels/_logging.py +115 -0
- squirrels/_manifest.py +208 -79
- squirrels/_model_builder.py +69 -0
- squirrels/_model_configs.py +74 -0
- squirrels/_model_queries.py +52 -0
- squirrels/_models.py +926 -367
- squirrels/_package_data/base_project/.env +42 -0
- squirrels/_package_data/base_project/.env.example +42 -0
- squirrels/_package_data/base_project/assets/expenses.db +0 -0
- squirrels/_package_data/base_project/connections.yml +16 -0
- squirrels/_package_data/base_project/dashboards/dashboard_example.py +34 -0
- squirrels/_package_data/base_project/dashboards/dashboard_example.yml +22 -0
- squirrels/{package_data → _package_data}/base_project/docker/.dockerignore +5 -2
- squirrels/{package_data → _package_data}/base_project/docker/Dockerfile +3 -3
- squirrels/{package_data → _package_data}/base_project/docker/compose.yml +1 -1
- squirrels/_package_data/base_project/duckdb_init.sql +10 -0
- squirrels/{package_data/base_project/.gitignore → _package_data/base_project/gitignore} +3 -2
- squirrels/_package_data/base_project/macros/macros_example.sql +17 -0
- squirrels/_package_data/base_project/models/builds/build_example.py +26 -0
- squirrels/_package_data/base_project/models/builds/build_example.sql +16 -0
- squirrels/_package_data/base_project/models/builds/build_example.yml +57 -0
- squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +12 -0
- squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +26 -0
- squirrels/_package_data/base_project/models/federates/federate_example.py +37 -0
- squirrels/_package_data/base_project/models/federates/federate_example.sql +19 -0
- squirrels/_package_data/base_project/models/federates/federate_example.yml +65 -0
- squirrels/_package_data/base_project/models/sources.yml +38 -0
- squirrels/{package_data → _package_data}/base_project/parameters.yml +56 -40
- squirrels/_package_data/base_project/pyconfigs/connections.py +14 -0
- squirrels/{package_data → _package_data}/base_project/pyconfigs/context.py +21 -40
- squirrels/_package_data/base_project/pyconfigs/parameters.py +141 -0
- squirrels/_package_data/base_project/pyconfigs/user.py +44 -0
- squirrels/_package_data/base_project/seeds/seed_categories.yml +15 -0
- squirrels/_package_data/base_project/seeds/seed_subcategories.csv +15 -0
- squirrels/_package_data/base_project/seeds/seed_subcategories.yml +21 -0
- squirrels/_package_data/base_project/squirrels.yml.j2 +61 -0
- squirrels/_package_data/templates/dataset_results.html +112 -0
- squirrels/_package_data/templates/oauth_login.html +271 -0
- squirrels/_package_data/templates/squirrels_studio.html +20 -0
- squirrels/_package_loader.py +8 -4
- squirrels/_parameter_configs.py +104 -103
- squirrels/_parameter_options.py +348 -0
- squirrels/_parameter_sets.py +57 -47
- squirrels/_parameters.py +1664 -0
- squirrels/_project.py +721 -0
- squirrels/_py_module.py +7 -5
- squirrels/_schemas/__init__.py +0 -0
- squirrels/_schemas/auth_models.py +167 -0
- squirrels/_schemas/query_param_models.py +75 -0
- squirrels/{_api_response_models.py → _schemas/response_models.py} +126 -47
- squirrels/_seeds.py +35 -16
- squirrels/_sources.py +110 -0
- squirrels/_utils.py +248 -73
- squirrels/_version.py +1 -1
- squirrels/arguments.py +7 -0
- squirrels/auth.py +4 -0
- squirrels/connections.py +3 -0
- squirrels/dashboards.py +2 -81
- squirrels/data_sources.py +14 -631
- squirrels/parameter_options.py +13 -348
- squirrels/parameters.py +14 -1266
- squirrels/types.py +16 -0
- squirrels-0.5.0.dist-info/METADATA +113 -0
- squirrels-0.5.0.dist-info/RECORD +97 -0
- {squirrels-0.4.0.dist-info → squirrels-0.5.0.dist-info}/WHEEL +1 -1
- squirrels-0.5.0.dist-info/entry_points.txt +3 -0
- {squirrels-0.4.0.dist-info → squirrels-0.5.0.dist-info/licenses}/LICENSE +1 -1
- squirrels/_authenticator.py +0 -85
- squirrels/_dashboards_io.py +0 -61
- squirrels/_environcfg.py +0 -84
- squirrels/arguments/init_time_args.py +0 -40
- squirrels/arguments/run_time_args.py +0 -208
- squirrels/package_data/assets/favicon.ico +0 -0
- squirrels/package_data/assets/index.css +0 -1
- squirrels/package_data/assets/index.js +0 -58
- squirrels/package_data/base_project/assets/expenses.db +0 -0
- squirrels/package_data/base_project/connections.yml +0 -7
- squirrels/package_data/base_project/dashboards/dashboard_example.py +0 -32
- squirrels/package_data/base_project/dashboards.yml +0 -10
- squirrels/package_data/base_project/env.yml +0 -29
- squirrels/package_data/base_project/models/dbviews/dbview_example.py +0 -47
- squirrels/package_data/base_project/models/dbviews/dbview_example.sql +0 -22
- squirrels/package_data/base_project/models/federates/federate_example.py +0 -21
- squirrels/package_data/base_project/models/federates/federate_example.sql +0 -3
- squirrels/package_data/base_project/pyconfigs/auth.py +0 -45
- squirrels/package_data/base_project/pyconfigs/connections.py +0 -19
- squirrels/package_data/base_project/pyconfigs/parameters.py +0 -95
- squirrels/package_data/base_project/seeds/seed_subcategories.csv +0 -15
- squirrels/package_data/base_project/squirrels.yml.j2 +0 -94
- squirrels/package_data/templates/index.html +0 -18
- squirrels/project.py +0 -378
- squirrels/user_base.py +0 -55
- squirrels-0.4.0.dist-info/METADATA +0 -117
- squirrels-0.4.0.dist-info/RECORD +0 -60
- squirrels-0.4.0.dist-info/entry_points.txt +0 -4
- /squirrels/{package_data → _package_data}/base_project/assets/weather.db +0 -0
- /squirrels/{package_data → _package_data}/base_project/seeds/seed_categories.csv +0 -0
- /squirrels/{package_data → _package_data}/base_project/tmp/.gitignore +0 -0
squirrels/_api_server.py
CHANGED
|
@@ -1,26 +1,82 @@
|
|
|
1
|
-
from
|
|
2
|
-
from
|
|
3
|
-
from fastapi import
|
|
4
|
-
from fastapi.responses import HTMLResponse, JSONResponse
|
|
1
|
+
from fastapi import FastAPI, Request, status
|
|
2
|
+
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
|
|
3
|
+
from fastapi.security import HTTPBearer
|
|
5
4
|
from fastapi.templating import Jinja2Templates
|
|
6
5
|
from fastapi.staticfiles import StaticFiles
|
|
7
|
-
from
|
|
8
|
-
from
|
|
9
|
-
from
|
|
10
|
-
from cachetools import TTLCache
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
|
+
from starlette.responses import Response as StarletteResponse
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
11
9
|
from argparse import Namespace
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
from . import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
from .
|
|
18
|
-
from .
|
|
19
|
-
from .
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
import io, time, mimetypes, traceback, uuid, asyncio, contextlib
|
|
14
|
+
|
|
15
|
+
from . import _constants as c, _utils as u, _parameter_sets as ps
|
|
16
|
+
from ._exceptions import InvalidInputError, ConfigurationError, FileExecutionError
|
|
17
|
+
from ._version import __version__, sq_major_version
|
|
18
|
+
from ._project import SquirrelsProject
|
|
19
|
+
|
|
20
|
+
# Import route modules
|
|
21
|
+
from ._api_routes.auth import AuthRoutes
|
|
22
|
+
from ._api_routes.project import ProjectRoutes
|
|
23
|
+
from ._api_routes.datasets import DatasetRoutes
|
|
24
|
+
from ._api_routes.dashboards import DashboardRoutes
|
|
25
|
+
from ._api_routes.data_management import DataManagementRoutes
|
|
26
|
+
|
|
27
|
+
# # Disabled for now, a 'bring your own OAuth2 server' approach will be provided in the future
|
|
28
|
+
# from ._api_routes.oauth2 import OAuth2Routes
|
|
20
29
|
|
|
21
30
|
mimetypes.add_type('application/javascript', '.js')
|
|
22
31
|
|
|
23
32
|
|
|
33
|
+
class SmartCORSMiddleware(BaseHTTPMiddleware):
|
|
34
|
+
"""
|
|
35
|
+
Custom CORS middleware that allows specific origins to use credentials
|
|
36
|
+
while still allowing all other origins without credentials.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, app, allowed_credential_origins: list[str], configurables_as_headers: list[str]):
|
|
40
|
+
super().__init__(app)
|
|
41
|
+
|
|
42
|
+
allowed_predefined_headers = ["Authorization", "Content-Type", "x-api-key", "x-orientation", "x-verify-params"]
|
|
43
|
+
|
|
44
|
+
self.allowed_credential_origins = allowed_credential_origins
|
|
45
|
+
self.allowed_request_headers = ",".join(allowed_predefined_headers + configurables_as_headers)
|
|
46
|
+
|
|
47
|
+
async def dispatch(self, request: Request, call_next):
|
|
48
|
+
origin = request.headers.get("origin")
|
|
49
|
+
|
|
50
|
+
# Handle preflight requests
|
|
51
|
+
if request.method == "OPTIONS":
|
|
52
|
+
response = StarletteResponse(status_code=200)
|
|
53
|
+
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
|
54
|
+
response.headers["Access-Control-Allow-Headers"] = self.allowed_request_headers
|
|
55
|
+
|
|
56
|
+
else:
|
|
57
|
+
# Call the next middleware/route
|
|
58
|
+
response: StarletteResponse = await call_next(request)
|
|
59
|
+
|
|
60
|
+
# Always expose the Applied-Username header
|
|
61
|
+
response.headers["Access-Control-Expose-Headers"] = "Applied-Username"
|
|
62
|
+
|
|
63
|
+
if origin:
|
|
64
|
+
scheme = u.get_scheme(request.url.hostname)
|
|
65
|
+
request_origin = f"{scheme}://{request.url.netloc}"
|
|
66
|
+
# Check if this origin is in the whitelist or if origin matches the host origin
|
|
67
|
+
if origin == request_origin or origin in self.allowed_credential_origins:
|
|
68
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
|
69
|
+
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
70
|
+
else:
|
|
71
|
+
# Allow all other origins but without credentials / cookies
|
|
72
|
+
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
73
|
+
else:
|
|
74
|
+
# No origin header (same-origin request or non-browser)
|
|
75
|
+
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
76
|
+
|
|
77
|
+
return response
|
|
78
|
+
|
|
79
|
+
|
|
24
80
|
class ApiServer:
|
|
25
81
|
def __init__(self, no_cache: bool, project: SquirrelsProject) -> None:
|
|
26
82
|
"""
|
|
@@ -32,41 +88,101 @@ class ApiServer:
|
|
|
32
88
|
self.no_cache = no_cache
|
|
33
89
|
self.project = project
|
|
34
90
|
self.logger = project._logger
|
|
35
|
-
|
|
91
|
+
self.env_vars = project._env_vars
|
|
36
92
|
self.j2_env = project._j2_env
|
|
37
|
-
self.env_cfg = project._env_cfg
|
|
38
93
|
self.manifest_cfg = project._manifest_cfg
|
|
39
94
|
self.seeds = project._seeds
|
|
40
95
|
self.conn_args = project._conn_args
|
|
41
96
|
self.conn_set = project._conn_set
|
|
42
|
-
self.authenticator = project.
|
|
97
|
+
self.authenticator = project._auth
|
|
43
98
|
self.param_args = project._param_args
|
|
44
99
|
self.param_cfg_set = project._param_cfg_set
|
|
45
100
|
self.context_func = project._context_func
|
|
46
|
-
self.model_files = project._model_files
|
|
47
101
|
self.dashboards = project._dashboards
|
|
102
|
+
|
|
103
|
+
self.mcp = FastMCP(
|
|
104
|
+
name="Squirrels",
|
|
105
|
+
stateless_http=True
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Initialize route modules
|
|
109
|
+
get_bearer_token = HTTPBearer(auto_error=False)
|
|
110
|
+
# self.oauth2_routes = OAuth2Routes(get_bearer_token, project, no_cache)
|
|
111
|
+
self.auth_routes = AuthRoutes(get_bearer_token, project, no_cache)
|
|
112
|
+
self.project_routes = ProjectRoutes(get_bearer_token, project, no_cache)
|
|
113
|
+
self.dataset_routes = DatasetRoutes(get_bearer_token, project, no_cache)
|
|
114
|
+
self.dashboard_routes = DashboardRoutes(get_bearer_token, project, no_cache)
|
|
115
|
+
self.data_management_routes = DataManagementRoutes(get_bearer_token, project, no_cache)
|
|
48
116
|
|
|
49
|
-
|
|
117
|
+
|
|
118
|
+
async def _refresh_datasource_params(self) -> None:
|
|
50
119
|
"""
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
Arguments:
|
|
54
|
-
uvicorn_args: List of arguments to pass to uvicorn.run. Currently only supports "host" and "port"
|
|
120
|
+
Background task to periodically refresh datasource parameter options.
|
|
121
|
+
Runs every N minutes as configured by SQRL_PARAMETERS__DATASOURCE_REFRESH_MINUTES (default: 60).
|
|
55
122
|
"""
|
|
56
|
-
|
|
123
|
+
refresh_minutes_str = self.env_vars.get(c.SQRL_PARAMETERS_DATASOURCE_REFRESH_MINUTES, "60")
|
|
124
|
+
try:
|
|
125
|
+
refresh_minutes = int(refresh_minutes_str)
|
|
126
|
+
if refresh_minutes <= 0:
|
|
127
|
+
self.logger.info(f"The value of {c.SQRL_PARAMETERS_DATASOURCE_REFRESH_MINUTES} is: {refresh_minutes_str} minutes")
|
|
128
|
+
self.logger.info(f"Datasource parameter refresh is disabled since the refresh interval is not positive.")
|
|
129
|
+
return
|
|
130
|
+
except ValueError:
|
|
131
|
+
self.logger.warning(f"Invalid value for {c.SQRL_PARAMETERS_DATASOURCE_REFRESH_MINUTES}: {refresh_minutes_str}. Must be an integer. Disabling datasource parameter refresh.")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
refresh_seconds = refresh_minutes * 60
|
|
135
|
+
self.logger.info(f"Starting datasource parameter refresh background task (every {refresh_minutes} minutes)")
|
|
57
136
|
|
|
137
|
+
while True:
|
|
138
|
+
try:
|
|
139
|
+
await asyncio.sleep(refresh_seconds)
|
|
140
|
+
self.logger.info("Refreshing datasource parameter options...")
|
|
141
|
+
|
|
142
|
+
# Fetch fresh dataframes from datasources in a thread pool to avoid blocking
|
|
143
|
+
loop = asyncio.get_running_loop()
|
|
144
|
+
default_conn_name = self.manifest_cfg.env_vars.get(c.SQRL_CONNECTIONS_DEFAULT_NAME_USED, "default")
|
|
145
|
+
df_dict = await loop.run_in_executor(
|
|
146
|
+
None,
|
|
147
|
+
ps.ParameterConfigsSetIO._get_df_dict_from_data_sources,
|
|
148
|
+
self.param_cfg_set,
|
|
149
|
+
default_conn_name,
|
|
150
|
+
self.seeds,
|
|
151
|
+
self.conn_set,
|
|
152
|
+
self.project._datalake_db_path
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Re-convert datasource parameters with fresh data
|
|
156
|
+
self.param_cfg_set._post_process_params(df_dict)
|
|
157
|
+
|
|
158
|
+
self.logger.info("Successfully refreshed datasource parameter options")
|
|
159
|
+
except asyncio.CancelledError:
|
|
160
|
+
self.logger.info("Datasource parameter refresh task cancelled")
|
|
161
|
+
break
|
|
162
|
+
except Exception as e:
|
|
163
|
+
self.logger.error(f"Error refreshing datasource parameter options: {e}", exc_info=True)
|
|
164
|
+
# Continue the loop even if there's an error
|
|
165
|
+
|
|
166
|
+
@asynccontextmanager
|
|
167
|
+
async def _run_background_tasks(self, app: FastAPI):
|
|
168
|
+
refresh_datasource_task = asyncio.create_task(self._refresh_datasource_params())
|
|
169
|
+
|
|
170
|
+
async with contextlib.AsyncExitStack() as stack:
|
|
171
|
+
await stack.enter_async_context(self.mcp.session_manager.run())
|
|
172
|
+
yield
|
|
173
|
+
|
|
174
|
+
refresh_datasource_task.cancel()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _get_tags_metadata(self) -> list[dict]:
|
|
58
178
|
tags_metadata = [
|
|
59
179
|
{
|
|
60
180
|
"name": "Project Metadata",
|
|
61
181
|
"description": "Get information on project such as name, version, and other API endpoints",
|
|
62
182
|
},
|
|
63
183
|
{
|
|
64
|
-
"name": "
|
|
65
|
-
"description": "
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
"name": "Catalogs",
|
|
69
|
-
"description": "Get catalog of datasets and dashboards with endpoints for their parameters and results",
|
|
184
|
+
"name": "Data Management",
|
|
185
|
+
"description": "Actions to update the data components of the project",
|
|
70
186
|
}
|
|
71
187
|
]
|
|
72
188
|
|
|
@@ -76,17 +192,60 @@ class ApiServer:
|
|
|
76
192
|
"description": f"Get parameters or results for dataset '{dataset_name}'",
|
|
77
193
|
})
|
|
78
194
|
|
|
79
|
-
for dashboard_name in self.
|
|
195
|
+
for dashboard_name in self.dashboards:
|
|
80
196
|
tags_metadata.append({
|
|
81
197
|
"name": f"Dashboard '{dashboard_name}'",
|
|
82
198
|
"description": f"Get parameters or results for dashboard '{dashboard_name}'",
|
|
83
199
|
})
|
|
84
200
|
|
|
201
|
+
tags_metadata.extend([
|
|
202
|
+
{
|
|
203
|
+
"name": "Authentication",
|
|
204
|
+
"description": "Submit authentication credentials and authorize with a session cookie",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
"name": "User Management",
|
|
208
|
+
"description": "Manage users and their attributes",
|
|
209
|
+
},
|
|
210
|
+
# {
|
|
211
|
+
# "name": "OAuth2",
|
|
212
|
+
# "description": "Authorize and get token using the OAuth2 protocol",
|
|
213
|
+
# },
|
|
214
|
+
])
|
|
215
|
+
return tags_metadata
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run(self, uvicorn_args: Namespace) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Runs the API server with uvicorn for CLI "squirrels run"
|
|
221
|
+
|
|
222
|
+
Arguments:
|
|
223
|
+
uvicorn_args: List of arguments to pass to uvicorn.run. Currently only supports "host" and "port"
|
|
224
|
+
"""
|
|
225
|
+
start = time.time()
|
|
226
|
+
|
|
227
|
+
squirrels_version_path = f'/api/squirrels/v{sq_major_version}'
|
|
228
|
+
project_name = self.manifest_cfg.project_variables.name
|
|
229
|
+
project_name_for_api = u.normalize_name_for_api(project_name)
|
|
230
|
+
project_label = self.manifest_cfg.project_variables.label
|
|
231
|
+
project_version = f"v{self.manifest_cfg.project_variables.major_version}"
|
|
232
|
+
project_metadata_path = squirrels_version_path + f"/project/{project_name_for_api}/{project_version}"
|
|
233
|
+
|
|
234
|
+
param_fields = self.param_cfg_set.get_all_api_field_info()
|
|
235
|
+
|
|
236
|
+
tags_metadata = self._get_tags_metadata()
|
|
237
|
+
|
|
85
238
|
app = FastAPI(
|
|
86
|
-
title=f"Squirrels APIs for '{
|
|
87
|
-
description="For specifying parameter selections to dataset APIs, you can choose between using query parameters with the GET method or using request body with the POST method"
|
|
239
|
+
title=f"Squirrels APIs for '{project_label}'", openapi_tags=tags_metadata,
|
|
240
|
+
description="For specifying parameter selections to dataset APIs, you can choose between using query parameters with the GET method or using request body with the POST method",
|
|
241
|
+
lifespan=self._run_background_tasks,
|
|
242
|
+
openapi_url=project_metadata_path+"/openapi.json",
|
|
243
|
+
docs_url=project_metadata_path+"/docs",
|
|
244
|
+
redoc_url=project_metadata_path+"/redoc"
|
|
88
245
|
)
|
|
89
246
|
|
|
247
|
+
app.add_middleware(SessionMiddleware, secret_key=self.env_vars.get(c.SQRL_SECRET_KEY, ""), max_age=None, same_site="none", https_only=True)
|
|
248
|
+
|
|
90
249
|
async def _log_request_run(request: Request) -> None:
|
|
91
250
|
headers = dict(request.scope["headers"])
|
|
92
251
|
request_id = uuid.uuid4().hex
|
|
@@ -104,9 +263,6 @@ class ApiServer:
|
|
|
104
263
|
data = {"request_method": request.method, "request_path": path, "request_params": params, "request_headers": headers_dict, "request_body": body}
|
|
105
264
|
info = {"request_id": request_id}
|
|
106
265
|
self.logger.info(f'Running request: {request.method} {path_with_params}', extra={"data": data, "info": info})
|
|
107
|
-
|
|
108
|
-
def _get_request_id(request: Request) -> str:
|
|
109
|
-
return request.headers.get("x-request-id", "")
|
|
110
266
|
|
|
111
267
|
@app.middleware("http")
|
|
112
268
|
async def catch_exceptions_middleware(request: Request, call_next):
|
|
@@ -114,439 +270,89 @@ class ApiServer:
|
|
|
114
270
|
try:
|
|
115
271
|
await _log_request_run(request)
|
|
116
272
|
return await call_next(request)
|
|
117
|
-
except
|
|
118
|
-
|
|
273
|
+
except InvalidInputError as exc:
|
|
274
|
+
message = str(exc)
|
|
275
|
+
self.logger.error(message)
|
|
119
276
|
response = JSONResponse(
|
|
120
|
-
status_code=
|
|
277
|
+
status_code=exc.status_code, content={"error": exc.error, "error_description": exc.error_description}
|
|
121
278
|
)
|
|
122
|
-
except
|
|
279
|
+
except FileExecutionError as exc:
|
|
123
280
|
traceback.print_exception(exc.error, file=buffer)
|
|
124
281
|
buffer.write(str(exc))
|
|
125
282
|
response = JSONResponse(
|
|
126
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels project"}
|
|
283
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected server error occurred", "blame": "Squirrels project"}
|
|
127
284
|
)
|
|
128
|
-
except
|
|
285
|
+
except ConfigurationError as exc:
|
|
129
286
|
traceback.print_exc(file=buffer)
|
|
130
287
|
response = JSONResponse(
|
|
131
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels project"}
|
|
288
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected server error occurred", "blame": "Squirrels project"}
|
|
132
289
|
)
|
|
133
290
|
except Exception as exc:
|
|
134
291
|
traceback.print_exc(file=buffer)
|
|
135
292
|
response = JSONResponse(
|
|
136
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels framework"}
|
|
293
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected server error occurred", "blame": "Squirrels framework"}
|
|
137
294
|
)
|
|
138
295
|
|
|
139
296
|
err_msg = buffer.getvalue()
|
|
140
|
-
|
|
141
|
-
|
|
297
|
+
if err_msg:
|
|
298
|
+
self.logger.error(err_msg)
|
|
299
|
+
# print(err_msg)
|
|
142
300
|
return response
|
|
143
301
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
squirrels_version_path = f'/squirrels-v{sq_major_version}'
|
|
150
|
-
partial_base_path = f'/{self.manifest_cfg.project_variables.name}/v{self.manifest_cfg.project_variables.major_version}'
|
|
151
|
-
base_path = squirrels_version_path + u.normalize_name_for_api(partial_base_path)
|
|
302
|
+
# Configure CORS with smart credential handling
|
|
303
|
+
# Get allowed origins for credentials from environment variable
|
|
304
|
+
credential_origins_env = self.env_vars.get(c.SQRL_AUTH_CREDENTIAL_ORIGINS, "https://squirrels-analytics.github.io")
|
|
305
|
+
allowed_credential_origins = [origin.strip() for origin in credential_origins_env.split(",") if origin.strip()]
|
|
306
|
+
configurables_as_headers = [f"x-config-{u.normalize_name_for_api(name)}" for name in self.manifest_cfg.configurables.keys()]
|
|
152
307
|
|
|
153
|
-
|
|
154
|
-
T = TypeVar('T')
|
|
155
|
-
|
|
156
|
-
def get_versioning_request_header(headers: Mapping, header_key: str):
|
|
157
|
-
header_value = headers.get(header_key)
|
|
158
|
-
if header_value is None:
|
|
159
|
-
return None
|
|
160
|
-
|
|
161
|
-
try:
|
|
162
|
-
result = int(header_value)
|
|
163
|
-
except ValueError:
|
|
164
|
-
raise u.InvalidInputError(f"Request header '{header_key}' must be an integer. Got '{header_value}'")
|
|
165
|
-
|
|
166
|
-
if result < 0 or result > int(sq_major_version):
|
|
167
|
-
raise u.InvalidInputError(f"Request header '{header_key}' not in valid range. Got '{result}'")
|
|
168
|
-
|
|
169
|
-
return result
|
|
170
|
-
|
|
171
|
-
def get_request_version_header(headers: Mapping):
|
|
172
|
-
REQUEST_VERSION_REQUEST_HEADER = "squirrels-request-version"
|
|
173
|
-
return get_versioning_request_header(headers, REQUEST_VERSION_REQUEST_HEADER)
|
|
174
|
-
|
|
175
|
-
def process_based_on_response_version_header(headers: Mapping, processes: dict[int, Callable[[], T]]) -> T:
|
|
176
|
-
RESPONSE_VERSION_REQUEST_HEADER = "squirrels-response-version"
|
|
177
|
-
response_version = get_versioning_request_header(headers, RESPONSE_VERSION_REQUEST_HEADER)
|
|
178
|
-
if response_version is None or response_version >= 0:
|
|
179
|
-
return processes[0]()
|
|
180
|
-
else:
|
|
181
|
-
raise u.InvalidInputError(f'Invalid value for "{RESPONSE_VERSION_REQUEST_HEADER}" header: {response_version}')
|
|
308
|
+
app.add_middleware(SmartCORSMiddleware, allowed_credential_origins=allowed_credential_origins, configurables_as_headers=configurables_as_headers)
|
|
182
309
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
QueryModelForPost = create_model("RequestBodyParams", **{
|
|
215
|
-
param: param_fields[param].as_body_info() for param in parameters
|
|
216
|
-
}) # type: ignore
|
|
217
|
-
return QueryModelForGet, QueryModelForPost
|
|
218
|
-
|
|
219
|
-
def _get_section_from_request_path(request: Request, section: int) -> str:
|
|
220
|
-
url_path: str = request.scope['route'].path
|
|
221
|
-
return url_path.split('/')[section]
|
|
222
|
-
|
|
223
|
-
def get_dataset_name(request: Request, section: int) -> str:
|
|
224
|
-
dataset_raw = _get_section_from_request_path(request, section)
|
|
225
|
-
return u.normalize_name(dataset_raw)
|
|
226
|
-
|
|
227
|
-
def get_dashboard_name(request: Request, section: int) -> str:
|
|
228
|
-
dashboard_raw = _get_section_from_request_path(request, section)
|
|
229
|
-
return u.normalize_name(dashboard_raw)
|
|
230
|
-
|
|
231
|
-
# Login & Authorization
|
|
232
|
-
token_path = base_path + '/token'
|
|
233
|
-
|
|
234
|
-
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=token_path, auto_error=False)
|
|
235
|
-
|
|
236
|
-
@app.post(token_path, tags=["Login"])
|
|
237
|
-
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()) -> arm.LoginReponse:
|
|
238
|
-
user: User | None = self.authenticator.authenticate_user(form_data.username, form_data.password)
|
|
239
|
-
if not user:
|
|
240
|
-
raise HTTPException(
|
|
241
|
-
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}
|
|
242
|
-
)
|
|
243
|
-
access_token, expiry = self.authenticator.create_access_token(user)
|
|
244
|
-
return arm.LoginReponse(access_token=access_token, token_type="bearer", username=user.username, expiry_time=expiry)
|
|
245
|
-
|
|
246
|
-
async def get_current_user(response: Response, token: str = Depends(oauth2_scheme)) -> User | None:
|
|
247
|
-
user = self.authenticator.get_user_from_token(token)
|
|
248
|
-
username = "" if user is None else user.username
|
|
249
|
-
response.headers["Applied-Username"] = username
|
|
250
|
-
return user
|
|
251
|
-
|
|
252
|
-
# Datasets / Dashboards Catalog API
|
|
253
|
-
data_catalog_path = base_path + '/data-catalog'
|
|
254
|
-
dataset_results_path = base_path + '/dataset/{dataset}'
|
|
255
|
-
dataset_parameters_path = dataset_results_path + '/parameters'
|
|
256
|
-
dashboard_results_path = base_path + '/dashboard/{dashboard}'
|
|
257
|
-
dashboard_parameters_path = dashboard_results_path + '/parameters'
|
|
258
|
-
|
|
259
|
-
def get_data_catalog0(user: User | None) -> arm.CatalogModel:
|
|
260
|
-
dataset_items: list[arm.DatasetItemModel] = []
|
|
261
|
-
for name, config in self.manifest_cfg.datasets.items():
|
|
262
|
-
if self.authenticator.can_user_access_scope(user, config.scope):
|
|
263
|
-
name_normalized = u.normalize_name_for_api(name)
|
|
264
|
-
dataset_items.append(arm.DatasetItemModel(
|
|
265
|
-
name=name, label=config.label, description=config.description,
|
|
266
|
-
parameters_path=dataset_parameters_path.format(dataset=name_normalized),
|
|
267
|
-
result_path=dataset_results_path.format(dataset=name_normalized)
|
|
268
|
-
))
|
|
269
|
-
|
|
270
|
-
dashboard_items: list[arm.DashboardItemModel] = []
|
|
271
|
-
for name, config in self.manifest_cfg.dashboards.items():
|
|
272
|
-
if self.authenticator.can_user_access_scope(user, config.scope):
|
|
273
|
-
name_normalized = u.normalize_name_for_api(name)
|
|
274
|
-
|
|
275
|
-
try:
|
|
276
|
-
dashboard_format = self.dashboards[name].get_dashboard_format()
|
|
277
|
-
except KeyError:
|
|
278
|
-
raise u.ConfigurationError(f"No dashboard file found for: {name}")
|
|
279
|
-
|
|
280
|
-
dashboard_items.append(arm.DashboardItemModel(
|
|
281
|
-
name=name, label=config.label, description=config.description, result_format=dashboard_format,
|
|
282
|
-
parameters_path=dashboard_parameters_path.format(dashboard=name_normalized),
|
|
283
|
-
result_path=dashboard_results_path.format(dashboard=name_normalized)
|
|
284
|
-
))
|
|
285
|
-
|
|
286
|
-
return arm.CatalogModel(datasets=dataset_items, dashboards=dashboard_items)
|
|
287
|
-
|
|
288
|
-
@app.get(data_catalog_path, tags=["Catalogs"], summary="Get list of datasets and dashboards available for user")
|
|
289
|
-
def get_catalog_of_datasets_and_dashboards(request: Request, user: User | None = Depends(get_current_user)) -> arm.CatalogModel:
|
|
290
|
-
return process_based_on_response_version_header(request.headers, {
|
|
291
|
-
0: lambda: get_data_catalog0(user)
|
|
292
|
-
})
|
|
293
|
-
|
|
294
|
-
# Parameters API Helpers
|
|
295
|
-
parameters_description = "Selections of one parameter may cascade the available options in another parameter. " \
|
|
296
|
-
"For example, if the dataset has parameters for 'country' and 'city', available options for 'city' would " \
|
|
297
|
-
"depend on the selected option 'country'. If a parameter has 'trigger_refresh' as true, provide the parameter " \
|
|
298
|
-
"selection to this endpoint whenever it changes to refresh the parameter options of children parameters."
|
|
299
|
-
|
|
300
|
-
async def get_parameters_helper(
|
|
301
|
-
parameters_tuple: tuple[str, ...], user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
302
|
-
) -> ParameterSet:
|
|
303
|
-
if len(selections) > 1:
|
|
304
|
-
raise u.InvalidInputError(f"The /parameters endpoint takes at most 1 query parameter. Got {dict(selections)}")
|
|
305
|
-
|
|
306
|
-
param_set = self.param_cfg_set.apply_selections(
|
|
307
|
-
parameters_tuple, dict(selections), user, updates_only=True, request_version=request_version
|
|
308
|
-
)
|
|
309
|
-
return param_set
|
|
310
|
-
|
|
311
|
-
settings = self.manifest_cfg.settings
|
|
312
|
-
parameters_cache_size = settings.get(c.PARAMETERS_CACHE_SIZE_SETTING, 1024)
|
|
313
|
-
parameters_cache_ttl = settings.get(c.PARAMETERS_CACHE_TTL_SETTING, 60)
|
|
314
|
-
params_cache = TTLCache(maxsize=parameters_cache_size, ttl=parameters_cache_ttl*60)
|
|
315
|
-
|
|
316
|
-
async def get_parameters_cachable(
|
|
317
|
-
parameters_tuple: tuple[str, ...], user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
318
|
-
) -> ParameterSet:
|
|
319
|
-
return await do_cachable_action(params_cache, get_parameters_helper, parameters_tuple, user, selections, request_version)
|
|
320
|
-
|
|
321
|
-
async def get_parameters_definition(
|
|
322
|
-
parameters_list: list[str], user: User | None, headers: Mapping, params: Mapping
|
|
323
|
-
) -> arm.ParametersModel:
|
|
324
|
-
get_parameters_function = get_parameters_helper if self.no_cache else get_parameters_cachable
|
|
325
|
-
selections, request_version = get_selections_and_request_version(params, headers)
|
|
326
|
-
result = await get_parameters_function(tuple(parameters_list), user, selections, request_version)
|
|
327
|
-
return process_based_on_response_version_header(headers, {
|
|
328
|
-
0: result.to_api_response_model0
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
param_fields = self.param_cfg_set.get_all_api_field_info()
|
|
332
|
-
|
|
333
|
-
def validate_parameters_list(parameters: list[str], entity_type: str) -> None:
|
|
334
|
-
for param in parameters:
|
|
335
|
-
if param not in param_fields:
|
|
336
|
-
all_params = list(param_fields.keys())
|
|
337
|
-
raise u.ConfigurationError(f"{entity_type} '{dataset_name}' use parameter '{param}' which doesn't exist. Available parameters are:"
|
|
338
|
-
f"\n {all_params}")
|
|
339
|
-
|
|
340
|
-
# Dataset Results API Helpers
|
|
341
|
-
async def get_dataset_results_helper(
|
|
342
|
-
dataset: str, user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
343
|
-
) -> pd.DataFrame:
|
|
344
|
-
try:
|
|
345
|
-
return await self.project.dataset(dataset, selections=dict(selections), user=user)
|
|
346
|
-
except PermissionError as e:
|
|
347
|
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e), headers={"WWW-Authenticate": "Bearer"}) from e
|
|
348
|
-
|
|
349
|
-
settings = self.manifest_cfg.settings
|
|
350
|
-
dataset_results_cache_size = settings.get(c.DATASETS_CACHE_SIZE_SETTING, settings.get(c.RESULTS_CACHE_SIZE_SETTING, 128))
|
|
351
|
-
dataset_results_cache_ttl = settings.get(c.DATASETS_CACHE_TTL_SETTING, settings.get(c.RESULTS_CACHE_TTL_SETTING, 60))
|
|
352
|
-
dataset_results_cache = TTLCache(maxsize=dataset_results_cache_size, ttl=dataset_results_cache_ttl*60)
|
|
353
|
-
|
|
354
|
-
async def get_dataset_results_cachable(
|
|
355
|
-
dataset: str, user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
356
|
-
) -> pd.DataFrame:
|
|
357
|
-
return await do_cachable_action(dataset_results_cache, get_dataset_results_helper, dataset, user, selections, request_version)
|
|
358
|
-
|
|
359
|
-
async def get_dataset_results_definition(
|
|
360
|
-
dataset_name: str, user: User | None, headers: Mapping, params: Mapping
|
|
361
|
-
) -> arm.DatasetResultModel:
|
|
362
|
-
get_dataset_function = get_dataset_results_helper if self.no_cache else get_dataset_results_cachable
|
|
363
|
-
selections, request_version = get_selections_and_request_version(params, headers)
|
|
364
|
-
result = await get_dataset_function(dataset_name, user, selections, request_version)
|
|
365
|
-
return process_based_on_response_version_header(headers, {
|
|
366
|
-
0: lambda: arm.DatasetResultModel(**u.df_to_json0(result))
|
|
367
|
-
})
|
|
368
|
-
|
|
369
|
-
# Dashboard Results API Helpers
|
|
370
|
-
async def get_dashboard_results_helper(
|
|
371
|
-
dashboard: str, user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
372
|
-
) -> Dashboard:
|
|
373
|
-
try:
|
|
374
|
-
return await self.project.dashboard(dashboard, selections=dict(selections), user=user)
|
|
375
|
-
except PermissionError as e:
|
|
376
|
-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e), headers={"WWW-Authenticate": "Bearer"}) from e
|
|
377
|
-
|
|
378
|
-
settings = self.manifest_cfg.settings
|
|
379
|
-
dashboard_results_cache_size = settings.get(c.DASHBOARDS_CACHE_SIZE_SETTING, 128)
|
|
380
|
-
dashboard_results_cache_ttl = settings.get(c.DASHBOARDS_CACHE_TTL_SETTING, 60)
|
|
381
|
-
dashboard_results_cache = TTLCache(maxsize=dashboard_results_cache_size, ttl=dashboard_results_cache_ttl*60)
|
|
382
|
-
|
|
383
|
-
async def get_dashboard_results_cachable(
|
|
384
|
-
dashboard: str, user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
|
|
385
|
-
) -> Dashboard:
|
|
386
|
-
return await do_cachable_action(dashboard_results_cache, get_dashboard_results_helper, dashboard, user, selections, request_version)
|
|
387
|
-
|
|
388
|
-
async def get_dashboard_results_definition(
|
|
389
|
-
dashboard_name: str, user: User | None, headers: Mapping, params: Mapping
|
|
390
|
-
) -> Response:
|
|
391
|
-
get_dashboard_function = get_dashboard_results_helper if self.no_cache else get_dashboard_results_cachable
|
|
392
|
-
selections, request_version = get_selections_and_request_version(params, headers)
|
|
393
|
-
dashboard_obj = await get_dashboard_function(dashboard_name, user, selections, request_version)
|
|
394
|
-
if dashboard_obj._format == c.PNG:
|
|
395
|
-
assert isinstance(dashboard_obj._content, bytes)
|
|
396
|
-
result = Response(dashboard_obj._content, media_type="image/png")
|
|
397
|
-
elif dashboard_obj._format == c.HTML:
|
|
398
|
-
result = HTMLResponse(dashboard_obj._content)
|
|
399
|
-
else:
|
|
400
|
-
raise NotImplementedError()
|
|
401
|
-
return result
|
|
402
|
-
|
|
403
|
-
# Dataset Parameters and Results APIs
|
|
404
|
-
for dataset_name, dataset_config in self.manifest_cfg.datasets.items():
|
|
405
|
-
dataset_normalized = u.normalize_name_for_api(dataset_name)
|
|
406
|
-
curr_parameters_path = dataset_parameters_path.format(dataset=dataset_normalized)
|
|
407
|
-
curr_results_path = dataset_results_path.format(dataset=dataset_normalized)
|
|
408
|
-
|
|
409
|
-
validate_parameters_list(dataset_config.parameters, "Dataset")
|
|
410
|
-
|
|
411
|
-
QueryModelForGet, QueryModelForPost = get_query_models_from_widget_params(dataset_config.parameters)
|
|
412
|
-
|
|
413
|
-
@app.get(
|
|
414
|
-
curr_parameters_path, tags=[f"Dataset '{dataset_name}'"], openapi_extra={"dataset": dataset_name},
|
|
415
|
-
description=parameters_description, response_class=JSONResponse
|
|
416
|
-
)
|
|
417
|
-
async def get_dataset_parameters(
|
|
418
|
-
request: Request, params: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
|
|
419
|
-
) -> arm.ParametersModel:
|
|
420
|
-
start = time.time()
|
|
421
|
-
curr_dataset_name = get_dataset_name(request, -2)
|
|
422
|
-
parameters_list = self.manifest_cfg.datasets[curr_dataset_name].parameters
|
|
423
|
-
result = await get_parameters_definition(parameters_list, user, request.headers, asdict(params))
|
|
424
|
-
self.logger.log_activity_time("GET REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
425
|
-
return result
|
|
426
|
-
|
|
427
|
-
@app.post(
|
|
428
|
-
curr_parameters_path, tags=[f"Dataset '{dataset_name}'"], openapi_extra={"dataset": dataset_name},
|
|
429
|
-
description=parameters_description, response_class=JSONResponse
|
|
430
|
-
)
|
|
431
|
-
async def get_dataset_parameters_with_post(
|
|
432
|
-
request: Request, params: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
|
|
433
|
-
) -> arm.ParametersModel:
|
|
434
|
-
start = time.time()
|
|
435
|
-
curr_dataset_name = get_dataset_name(request, -2)
|
|
436
|
-
parameters_list = self.manifest_cfg.datasets[curr_dataset_name].parameters
|
|
437
|
-
params: BaseModel = params
|
|
438
|
-
result = await get_parameters_definition(parameters_list, user, request.headers, params.model_dump())
|
|
439
|
-
self.logger.log_activity_time("POST REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
440
|
-
return result
|
|
441
|
-
|
|
442
|
-
@app.get(curr_results_path, tags=[f"Dataset '{dataset_name}'"], description=dataset_config.description, response_class=JSONResponse)
|
|
443
|
-
async def get_dataset_results(
|
|
444
|
-
request: Request, params: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
|
|
445
|
-
) -> arm.DatasetResultModel:
|
|
446
|
-
start = time.time()
|
|
447
|
-
curr_dataset_name = get_dataset_name(request, -1)
|
|
448
|
-
result = await get_dataset_results_definition(curr_dataset_name, user, request.headers, asdict(params))
|
|
449
|
-
self.logger.log_activity_time("GET REQUEST for DATASET RESULTS", start, request_id=_get_request_id(request))
|
|
450
|
-
return result
|
|
451
|
-
|
|
452
|
-
@app.post(curr_results_path, tags=[f"Dataset '{dataset_name}'"], description=dataset_config.description, response_class=JSONResponse)
|
|
453
|
-
async def get_dataset_results_with_post(
|
|
454
|
-
request: Request, params: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
|
|
455
|
-
) -> arm.DatasetResultModel:
|
|
456
|
-
start = time.time()
|
|
457
|
-
curr_dataset_name = get_dataset_name(request, -1)
|
|
458
|
-
params: BaseModel = params
|
|
459
|
-
result = await get_dataset_results_definition(curr_dataset_name, user, request.headers, params.model_dump())
|
|
460
|
-
self.logger.log_activity_time("POST REQUEST for DATASET RESULTS", start, request_id=_get_request_id(request))
|
|
461
|
-
return result
|
|
310
|
+
# Setup route modules
|
|
311
|
+
# self.oauth2_routes.setup_routes(app, squirrels_version_path)
|
|
312
|
+
self.auth_routes.setup_routes(app, squirrels_version_path)
|
|
313
|
+
get_parameters_definition = self.project_routes.setup_routes(app, self.mcp, project_metadata_path, project_name, project_version, project_label, param_fields)
|
|
314
|
+
self.data_management_routes.setup_routes(app, project_metadata_path, param_fields)
|
|
315
|
+
self.dataset_routes.setup_routes(app, self.mcp, project_metadata_path, project_name, project_label, param_fields, get_parameters_definition)
|
|
316
|
+
self.dashboard_routes.setup_routes(app, project_metadata_path, param_fields, get_parameters_definition)
|
|
317
|
+
app.mount(project_metadata_path, self.mcp.streamable_http_app())
|
|
318
|
+
|
|
319
|
+
# Mount static files from public directory if it exists
|
|
320
|
+
# This allows users to serve static assets (images, CSS, JS, etc.) from {project_path}/public/
|
|
321
|
+
public_dir = Path(self.project._filepath) / c.PUBLIC_FOLDER
|
|
322
|
+
if public_dir.exists() and public_dir.is_dir():
|
|
323
|
+
app.mount("/public", StaticFiles(directory=str(public_dir)), name="public")
|
|
324
|
+
self.logger.info(f"Mounted static files from: {public_dir}")
|
|
325
|
+
|
|
326
|
+
# Add Root Path Redirection to Squirrels Studio
|
|
327
|
+
full_hostname = f"http://{uvicorn_args.host}:{uvicorn_args.port}"
|
|
328
|
+
squirrels_studio_path = f"/project/{project_name_for_api}/{project_version}/studio"
|
|
329
|
+
templates = Jinja2Templates(directory=str(Path(__file__).parent / "_package_data" / "templates"))
|
|
330
|
+
|
|
331
|
+
@app.get(squirrels_studio_path, include_in_schema=False)
|
|
332
|
+
async def squirrels_studio():
|
|
333
|
+
default_studio_path = "https://squirrels-analytics.github.io/squirrels-studio-v1"
|
|
334
|
+
sqrl_studio_base_url = self.env_vars.get(c.SQRL_STUDIO_BASE_URL, default_studio_path)
|
|
335
|
+
context = {
|
|
336
|
+
"sqrl_studio_base_url": sqrl_studio_base_url,
|
|
337
|
+
"project_name": project_name_for_api,
|
|
338
|
+
"project_version": project_version,
|
|
339
|
+
}
|
|
340
|
+
return HTMLResponse(content=templates.get_template("squirrels_studio.html").render(context))
|
|
462
341
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
curr_parameters_path = dashboard_parameters_path.format(dashboard=dashboard_normalized)
|
|
467
|
-
curr_results_path = dashboard_results_path.format(dashboard=dashboard_normalized)
|
|
468
|
-
|
|
469
|
-
validate_parameters_list(dashboard_config.parameters, "Dashboard")
|
|
470
|
-
|
|
471
|
-
QueryModelForGet, QueryModelForPost = get_query_models_from_widget_params(dashboard_config.parameters)
|
|
342
|
+
@app.get("/", include_in_schema=False)
|
|
343
|
+
async def redirect_to_studio():
|
|
344
|
+
return RedirectResponse(url=squirrels_studio_path)
|
|
472
345
|
|
|
473
|
-
|
|
474
|
-
async def get_dashboard_parameters(
|
|
475
|
-
request: Request, params: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
|
|
476
|
-
) -> arm.ParametersModel:
|
|
477
|
-
start = time.time()
|
|
478
|
-
curr_dashboard_name = get_dashboard_name(request, -2)
|
|
479
|
-
parameters_list = self.manifest_cfg.dashboards[curr_dashboard_name].parameters
|
|
480
|
-
result = await get_parameters_definition(parameters_list, user, request.headers, asdict(params))
|
|
481
|
-
self.logger.log_activity_time("GET REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
482
|
-
return result
|
|
483
|
-
|
|
484
|
-
@app.post(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=parameters_description, response_class=JSONResponse)
|
|
485
|
-
async def get_dashboard_parameters_with_post(
|
|
486
|
-
request: Request, params: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
|
|
487
|
-
) -> arm.ParametersModel:
|
|
488
|
-
start = time.time()
|
|
489
|
-
curr_dashboard_name = get_dashboard_name(request, -2)
|
|
490
|
-
parameters_list = self.manifest_cfg.dashboards[curr_dashboard_name].parameters
|
|
491
|
-
params: BaseModel = params
|
|
492
|
-
result = await get_parameters_definition(parameters_list, user, request.headers, params.model_dump())
|
|
493
|
-
self.logger.log_activity_time("POST REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
494
|
-
return result
|
|
495
|
-
|
|
496
|
-
@app.get(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard_config.description, response_class=Response)
|
|
497
|
-
async def get_dashboard_results(
|
|
498
|
-
request: Request, params: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
|
|
499
|
-
) -> Response:
|
|
500
|
-
start = time.time()
|
|
501
|
-
curr_dashboard_name = get_dashboard_name(request, -1)
|
|
502
|
-
result = await get_dashboard_results_definition(curr_dashboard_name, user, request.headers, asdict(params))
|
|
503
|
-
self.logger.log_activity_time("GET REQUEST for DASHBOARD RESULTS", start, request_id=_get_request_id(request))
|
|
504
|
-
return result
|
|
505
|
-
|
|
506
|
-
@app.post(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard_config.description, response_class=Response)
|
|
507
|
-
async def get_dashboard_results_with_post(
|
|
508
|
-
request: Request, params: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
|
|
509
|
-
) -> Response:
|
|
510
|
-
start = time.time()
|
|
511
|
-
curr_dashboard_name = get_dashboard_name(request, -1)
|
|
512
|
-
params: BaseModel = params
|
|
513
|
-
result = await get_dashboard_results_definition(curr_dashboard_name, user, request.headers, params.model_dump())
|
|
514
|
-
self.logger.log_activity_time("POST REQUEST for DASHBOARD RESULTS", start, request_id=_get_request_id(request))
|
|
515
|
-
return result
|
|
346
|
+
self.logger.log_activity_time("creating app server", start)
|
|
516
347
|
|
|
517
|
-
#
|
|
518
|
-
|
|
519
|
-
return arm.ProjectModel(
|
|
520
|
-
name=self.manifest_cfg.project_variables.name,
|
|
521
|
-
label=self.manifest_cfg.project_variables.label,
|
|
522
|
-
versions=[arm.ProjectVersionModel(
|
|
523
|
-
major_version=self.manifest_cfg.project_variables.major_version,
|
|
524
|
-
minor_versions=[0],
|
|
525
|
-
token_path=token_path,
|
|
526
|
-
data_catalog_path=data_catalog_path
|
|
527
|
-
)]
|
|
528
|
-
)
|
|
529
|
-
|
|
530
|
-
@app.get(squirrels_version_path, tags=["Project Metadata"], response_class=JSONResponse)
|
|
531
|
-
async def get_project_metadata(request: Request) -> arm.ProjectModel:
|
|
532
|
-
return process_based_on_response_version_header(request.headers, {
|
|
533
|
-
0: lambda: get_project_metadata0()
|
|
534
|
-
})
|
|
348
|
+
# Run the API Server
|
|
349
|
+
import uvicorn
|
|
535
350
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
@app.get('/', summary="Get the Squirrels Testing UI", response_class=HTMLResponse)
|
|
544
|
-
async def get_testing_ui(request: Request):
|
|
545
|
-
return templates.TemplateResponse('index.html', {
|
|
546
|
-
'request': request, 'project_metadata_path': squirrels_version_path, 'token_path': token_path
|
|
547
|
-
})
|
|
351
|
+
print("\nWelcome to the Squirrels Data Application!\n")
|
|
352
|
+
print(f"- Application UI: {full_hostname}{squirrels_studio_path}")
|
|
353
|
+
print(f"- API Docs (with ReDoc): {full_hostname}{project_metadata_path}/redoc")
|
|
354
|
+
print(f"- API Docs (with Swagger UI): {full_hostname}{project_metadata_path}/docs")
|
|
355
|
+
print(f"- MCP Server URL: {full_hostname}{project_metadata_path}/mcp")
|
|
356
|
+
print()
|
|
548
357
|
|
|
549
|
-
|
|
550
|
-
import uvicorn
|
|
551
|
-
self.logger.log_activity_time("creating app server", start)
|
|
552
|
-
uvicorn.run(app, host=uvicorn_args.host, port=uvicorn_args.port)
|
|
358
|
+
uvicorn.run(app, host=uvicorn_args.host, port=uvicorn_args.port, proxy_headers=True, forwarded_allow_ips="*")
|