squirrels 0.4.0__py3-none-any.whl → 0.5.0rc0__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.
- squirrels/__init__.py +10 -6
- squirrels/_api_response_models.py +93 -44
- squirrels/_api_server.py +571 -219
- squirrels/_auth.py +451 -0
- squirrels/_command_line.py +61 -20
- squirrels/_connection_set.py +38 -25
- squirrels/_constants.py +44 -34
- squirrels/_dashboards_io.py +34 -16
- squirrels/_exceptions.py +57 -0
- squirrels/_initializer.py +117 -44
- squirrels/_manifest.py +124 -62
- squirrels/_model_builder.py +111 -0
- squirrels/_model_configs.py +74 -0
- squirrels/_model_queries.py +52 -0
- squirrels/_models.py +860 -354
- squirrels/_package_loader.py +8 -4
- squirrels/_parameter_configs.py +45 -65
- squirrels/_parameter_sets.py +15 -13
- squirrels/_project.py +561 -0
- squirrels/_py_module.py +4 -3
- squirrels/_seeds.py +35 -16
- squirrels/_sources.py +106 -0
- squirrels/_utils.py +166 -63
- squirrels/_version.py +1 -1
- squirrels/arguments/init_time_args.py +78 -15
- squirrels/arguments/run_time_args.py +62 -101
- squirrels/dashboards.py +4 -4
- squirrels/data_sources.py +94 -162
- squirrels/dataset_result.py +86 -0
- squirrels/dateutils.py +4 -4
- squirrels/package_data/base_project/.env +30 -0
- squirrels/package_data/base_project/.env.example +30 -0
- squirrels/package_data/base_project/.gitignore +3 -2
- squirrels/package_data/base_project/assets/expenses.db +0 -0
- squirrels/package_data/base_project/connections.yml +11 -3
- squirrels/package_data/base_project/dashboards/dashboard_example.py +15 -13
- squirrels/package_data/base_project/dashboards/dashboard_example.yml +22 -0
- squirrels/package_data/base_project/docker/.dockerignore +5 -2
- squirrels/package_data/base_project/docker/Dockerfile +3 -3
- squirrels/package_data/base_project/docker/compose.yml +1 -1
- squirrels/package_data/base_project/duckdb_init.sql +9 -0
- squirrels/package_data/base_project/macros/macros_example.sql +15 -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 +55 -0
- squirrels/package_data/base_project/models/dbviews/dbview_example.sql +12 -22
- squirrels/package_data/base_project/models/dbviews/dbview_example.yml +26 -0
- squirrels/package_data/base_project/models/federates/federate_example.py +38 -15
- squirrels/package_data/base_project/models/federates/federate_example.sql +16 -2
- squirrels/package_data/base_project/models/federates/federate_example.yml +65 -0
- squirrels/package_data/base_project/models/sources.yml +39 -0
- squirrels/package_data/base_project/parameters.yml +36 -21
- squirrels/package_data/base_project/pyconfigs/connections.py +6 -11
- squirrels/package_data/base_project/pyconfigs/context.py +20 -33
- squirrels/package_data/base_project/pyconfigs/parameters.py +19 -21
- squirrels/package_data/base_project/pyconfigs/user.py +23 -0
- squirrels/package_data/base_project/seeds/seed_categories.yml +15 -0
- squirrels/package_data/base_project/seeds/seed_subcategories.csv +15 -15
- squirrels/package_data/base_project/seeds/seed_subcategories.yml +21 -0
- squirrels/package_data/base_project/squirrels.yml.j2 +17 -40
- squirrels/parameters.py +20 -20
- {squirrels-0.4.0.dist-info → squirrels-0.5.0rc0.dist-info}/METADATA +31 -32
- squirrels-0.5.0rc0.dist-info/RECORD +70 -0
- {squirrels-0.4.0.dist-info → squirrels-0.5.0rc0.dist-info}/WHEEL +1 -1
- squirrels-0.5.0rc0.dist-info/entry_points.txt +3 -0
- {squirrels-0.4.0.dist-info → squirrels-0.5.0rc0.dist-info/licenses}/LICENSE +1 -1
- squirrels/_authenticator.py +0 -85
- squirrels/_environcfg.py +0 -84
- 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/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/pyconfigs/auth.py +0 -45
- 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/RECORD +0 -60
- squirrels-0.4.0.dist-info/entry_points.txt +0 -4
squirrels/_api_server.py
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
from typing import Coroutine, Mapping, Callable, TypeVar, Annotated, Any
|
|
2
2
|
from dataclasses import make_dataclass, asdict
|
|
3
|
-
from fastapi import Depends, FastAPI, Request,
|
|
4
|
-
from fastapi.responses import HTMLResponse, JSONResponse
|
|
5
|
-
from fastapi.templating import Jinja2Templates
|
|
6
|
-
from fastapi.staticfiles import StaticFiles
|
|
3
|
+
from fastapi import Depends, FastAPI, Request, Response, status
|
|
4
|
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
7
5
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|
8
6
|
from fastapi.middleware.cors import CORSMiddleware
|
|
9
|
-
from pydantic import create_model, BaseModel
|
|
7
|
+
from pydantic import create_model, BaseModel, Field
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
10
9
|
from cachetools import TTLCache
|
|
11
10
|
from argparse import Namespace
|
|
12
|
-
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import io, time, mimetypes, traceback, uuid, asyncio, urllib.parse
|
|
13
13
|
|
|
14
14
|
from . import _constants as c, _utils as u, _api_response_models as arm
|
|
15
|
-
from .
|
|
16
|
-
from .
|
|
15
|
+
from ._exceptions import InvalidInputError, ConfigurationError, FileExecutionError
|
|
16
|
+
from ._version import __version__, sq_major_version
|
|
17
|
+
from ._manifest import PermissionScope
|
|
18
|
+
from ._auth import BaseUser, AccessToken, UserField
|
|
17
19
|
from ._parameter_sets import ParameterSet
|
|
18
20
|
from .dashboards import Dashboard
|
|
19
|
-
from .
|
|
21
|
+
from ._project import SquirrelsProject
|
|
22
|
+
from .dataset_result import DatasetResult
|
|
23
|
+
from ._parameter_configs import APIParamFieldInfo
|
|
20
24
|
|
|
21
25
|
mimetypes.add_type('application/javascript', '.js')
|
|
22
26
|
|
|
@@ -32,20 +36,54 @@ class ApiServer:
|
|
|
32
36
|
self.no_cache = no_cache
|
|
33
37
|
self.project = project
|
|
34
38
|
self.logger = project._logger
|
|
35
|
-
|
|
39
|
+
self.env_vars = project._env_vars
|
|
36
40
|
self.j2_env = project._j2_env
|
|
37
|
-
self.env_cfg = project._env_cfg
|
|
38
41
|
self.manifest_cfg = project._manifest_cfg
|
|
39
42
|
self.seeds = project._seeds
|
|
40
43
|
self.conn_args = project._conn_args
|
|
41
44
|
self.conn_set = project._conn_set
|
|
42
|
-
self.authenticator = project.
|
|
45
|
+
self.authenticator = project._auth
|
|
43
46
|
self.param_args = project._param_args
|
|
44
47
|
self.param_cfg_set = project._param_cfg_set
|
|
45
48
|
self.context_func = project._context_func
|
|
46
|
-
self.model_files = project._model_files
|
|
47
49
|
self.dashboards = project._dashboards
|
|
48
50
|
|
|
51
|
+
|
|
52
|
+
async def _monitor_for_staging_file(self) -> None:
|
|
53
|
+
"""Background task that monitors for staging file and renames it when present"""
|
|
54
|
+
duckdb_venv_path = self.project._duckdb_venv_path
|
|
55
|
+
staging_file = Path(duckdb_venv_path + ".stg")
|
|
56
|
+
target_file = Path(duckdb_venv_path)
|
|
57
|
+
|
|
58
|
+
while True:
|
|
59
|
+
try:
|
|
60
|
+
if staging_file.exists():
|
|
61
|
+
try:
|
|
62
|
+
staging_file.replace(target_file)
|
|
63
|
+
self.logger.info("Successfully renamed staging database to virtual environment database")
|
|
64
|
+
except OSError:
|
|
65
|
+
# Silently continue if file cannot be renamed (will retry next iteration)
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
except Exception as e:
|
|
69
|
+
# Log any unexpected errors but keep running
|
|
70
|
+
self.logger.error(f"Error in monitoring {c.DUCKDB_VENV_FILE + '.stg'}: {str(e)}")
|
|
71
|
+
|
|
72
|
+
await asyncio.sleep(1) # Check every second
|
|
73
|
+
|
|
74
|
+
@asynccontextmanager
|
|
75
|
+
async def _run_background_tasks(self, app: FastAPI):
|
|
76
|
+
task = asyncio.create_task(self._monitor_for_staging_file())
|
|
77
|
+
yield
|
|
78
|
+
task.cancel()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _validate_request_params(self, all_request_params: Mapping, params: Mapping) -> None:
|
|
82
|
+
invalid_params = [param for param in all_request_params if param not in params]
|
|
83
|
+
if params.get("x_verify_params", False) and invalid_params:
|
|
84
|
+
raise InvalidInputError(201, f"Invalid query parameters: {', '.join(invalid_params)}")
|
|
85
|
+
|
|
86
|
+
|
|
49
87
|
def run(self, uvicorn_args: Namespace) -> None:
|
|
50
88
|
"""
|
|
51
89
|
Runs the API server with uvicorn for CLI "squirrels run"
|
|
@@ -55,18 +93,29 @@ class ApiServer:
|
|
|
55
93
|
"""
|
|
56
94
|
start = time.time()
|
|
57
95
|
|
|
96
|
+
squirrels_version_path = f'/api/squirrels-v{sq_major_version}'
|
|
97
|
+
project_name = u.normalize_name_for_api(self.manifest_cfg.project_variables.name)
|
|
98
|
+
project_version = f"v{self.manifest_cfg.project_variables.major_version}"
|
|
99
|
+
project_metadata_path = squirrels_version_path + f"/project/{project_name}/{project_version}"
|
|
100
|
+
|
|
101
|
+
param_fields = self.param_cfg_set.get_all_api_field_info()
|
|
102
|
+
|
|
58
103
|
tags_metadata = [
|
|
59
104
|
{
|
|
60
|
-
"name": "
|
|
61
|
-
"description": "
|
|
105
|
+
"name": "Authentication",
|
|
106
|
+
"description": "Submit authentication credentials, and get token for authentication",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"name": "User Management",
|
|
110
|
+
"description": "Manage users and their attributes",
|
|
62
111
|
},
|
|
63
112
|
{
|
|
64
|
-
"name": "
|
|
65
|
-
"description": "
|
|
113
|
+
"name": "Project Metadata",
|
|
114
|
+
"description": "Get information on project such as name, version, and other API endpoints",
|
|
66
115
|
},
|
|
67
116
|
{
|
|
68
|
-
"name": "
|
|
69
|
-
"description": "
|
|
117
|
+
"name": "Data Management",
|
|
118
|
+
"description": "Actions to update the data components of the project",
|
|
70
119
|
}
|
|
71
120
|
]
|
|
72
121
|
|
|
@@ -76,7 +125,7 @@ class ApiServer:
|
|
|
76
125
|
"description": f"Get parameters or results for dataset '{dataset_name}'",
|
|
77
126
|
})
|
|
78
127
|
|
|
79
|
-
for dashboard_name in self.
|
|
128
|
+
for dashboard_name in self.dashboards:
|
|
80
129
|
tags_metadata.append({
|
|
81
130
|
"name": f"Dashboard '{dashboard_name}'",
|
|
82
131
|
"description": f"Get parameters or results for dashboard '{dashboard_name}'",
|
|
@@ -84,7 +133,11 @@ class ApiServer:
|
|
|
84
133
|
|
|
85
134
|
app = FastAPI(
|
|
86
135
|
title=f"Squirrels APIs for '{self.manifest_cfg.project_variables.label}'", openapi_tags=tags_metadata,
|
|
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"
|
|
136
|
+
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",
|
|
137
|
+
lifespan=self._run_background_tasks,
|
|
138
|
+
openapi_url=project_metadata_path+"/openapi.json",
|
|
139
|
+
docs_url=project_metadata_path+"/docs",
|
|
140
|
+
redoc_url=project_metadata_path+"/redoc"
|
|
88
141
|
)
|
|
89
142
|
|
|
90
143
|
async def _log_request_run(request: Request) -> None:
|
|
@@ -114,18 +167,31 @@ class ApiServer:
|
|
|
114
167
|
try:
|
|
115
168
|
await _log_request_run(request)
|
|
116
169
|
return await call_next(request)
|
|
117
|
-
except
|
|
170
|
+
except InvalidInputError as exc:
|
|
118
171
|
traceback.print_exc(file=buffer)
|
|
172
|
+
message = str(exc)
|
|
173
|
+
if exc.error_code < 20:
|
|
174
|
+
status_code = status.HTTP_401_UNAUTHORIZED
|
|
175
|
+
elif exc.error_code < 40:
|
|
176
|
+
status_code = status.HTTP_403_FORBIDDEN
|
|
177
|
+
elif exc.error_code < 60:
|
|
178
|
+
status_code = status.HTTP_404_NOT_FOUND
|
|
179
|
+
elif exc.error_code < 70:
|
|
180
|
+
if exc.error_code == 61:
|
|
181
|
+
message = "The dataset depends on static data models that cannot be found. You may need to build the virtual data environment first."
|
|
182
|
+
status_code = status.HTTP_409_CONFLICT
|
|
183
|
+
else:
|
|
184
|
+
status_code = status.HTTP_400_BAD_REQUEST
|
|
119
185
|
response = JSONResponse(
|
|
120
|
-
status_code=
|
|
186
|
+
status_code=status_code, content={"message": message, "blame": "API client", "error_code": exc.error_code}
|
|
121
187
|
)
|
|
122
|
-
except
|
|
188
|
+
except FileExecutionError as exc:
|
|
123
189
|
traceback.print_exception(exc.error, file=buffer)
|
|
124
190
|
buffer.write(str(exc))
|
|
125
191
|
response = JSONResponse(
|
|
126
192
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels project"}
|
|
127
193
|
)
|
|
128
|
-
except
|
|
194
|
+
except ConfigurationError as exc:
|
|
129
195
|
traceback.print_exc(file=buffer)
|
|
130
196
|
response = JSONResponse(
|
|
131
197
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"message": f"An unexpected error occurred", "blame": "Squirrels project"}
|
|
@@ -146,56 +212,22 @@ class ApiServer:
|
|
|
146
212
|
expose_headers=["Applied-Username"]
|
|
147
213
|
)
|
|
148
214
|
|
|
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)
|
|
152
|
-
|
|
153
215
|
# Helpers
|
|
154
216
|
T = TypeVar('T')
|
|
155
217
|
|
|
156
|
-
def
|
|
157
|
-
|
|
158
|
-
|
|
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}')
|
|
182
|
-
|
|
183
|
-
def get_selections_and_request_version(params: Mapping, headers: Mapping) -> tuple[frozenset[tuple[str, Any]], int | None]:
|
|
184
|
-
# Changing selections into a cachable "frozenset" that will later be converted to dictionary
|
|
185
|
-
selections = set()
|
|
218
|
+
def get_selections_as_immutable(params: Mapping, uncached_keys: set[str]) -> tuple[tuple[str, Any], ...]:
|
|
219
|
+
# Changing selections into a cachable "tuple of pairs" that will later be converted to dictionary
|
|
220
|
+
selections = list()
|
|
186
221
|
for key, val in params.items():
|
|
187
|
-
if val is None:
|
|
222
|
+
if key in uncached_keys or val is None:
|
|
188
223
|
continue
|
|
189
224
|
if isinstance(val, (list, tuple)):
|
|
190
225
|
if len(val) == 1: # for backward compatibility
|
|
191
226
|
val = val[0]
|
|
192
227
|
else:
|
|
193
228
|
val = tuple(val)
|
|
194
|
-
selections.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
request_version = get_request_version_header(headers)
|
|
198
|
-
return selections, request_version
|
|
229
|
+
selections.append((u.normalize_name(key), val))
|
|
230
|
+
return tuple(selections)
|
|
199
231
|
|
|
200
232
|
async def do_cachable_action(cache: TTLCache, action: Callable[..., Coroutine[Any, Any, T]], *args) -> T:
|
|
201
233
|
cache_key = tuple(args)
|
|
@@ -204,18 +236,55 @@ class ApiServer:
|
|
|
204
236
|
result = await action(*args)
|
|
205
237
|
cache[cache_key] = result
|
|
206
238
|
return result
|
|
207
|
-
|
|
208
|
-
def
|
|
239
|
+
|
|
240
|
+
def _get_query_models_helper(widget_parameters: list[str] | None, predefined_params: list[APIParamFieldInfo]):
|
|
241
|
+
if widget_parameters is None:
|
|
242
|
+
widget_parameters = list(param_fields.keys())
|
|
243
|
+
|
|
209
244
|
QueryModelForGetRaw = make_dataclass("QueryParams", [
|
|
210
|
-
param_fields[param].as_query_info() for param in
|
|
211
|
-
])
|
|
245
|
+
param_fields[param].as_query_info() for param in widget_parameters
|
|
246
|
+
] + [param.as_query_info() for param in predefined_params])
|
|
212
247
|
QueryModelForGet = Annotated[QueryModelForGetRaw, Depends()]
|
|
213
248
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
249
|
+
field_definitions = {param: param_fields[param].as_body_info() for param in widget_parameters}
|
|
250
|
+
for param in predefined_params:
|
|
251
|
+
field_definitions[param.name] = param.as_body_info()
|
|
252
|
+
QueryModelForPost = create_model("RequestBodyParams", **field_definitions) # type: ignore
|
|
217
253
|
return QueryModelForGet, QueryModelForPost
|
|
218
254
|
|
|
255
|
+
def get_query_models_for_parameters(widget_parameters: list[str] | None):
|
|
256
|
+
predefined_params = [
|
|
257
|
+
APIParamFieldInfo("x_verify_params", bool, default=False, description="If true, the query parameters are verified to be valid for the dataset"),
|
|
258
|
+
APIParamFieldInfo("x_parent_param", str, description="The parameter name used for parameter updates. If not provided, then all parameters are retrieved"),
|
|
259
|
+
]
|
|
260
|
+
return _get_query_models_helper(widget_parameters, predefined_params)
|
|
261
|
+
|
|
262
|
+
def get_query_models_for_dataset(widget_parameters: list[str] | None):
|
|
263
|
+
predefined_params = [
|
|
264
|
+
APIParamFieldInfo("x_verify_params", bool, default=False, description="If true, the query parameters are verified to be valid for the dataset"),
|
|
265
|
+
APIParamFieldInfo("x_orientation", str, default="records", description="The orientation of the data to return, one of: 'records', 'rows', or 'columns'"),
|
|
266
|
+
APIParamFieldInfo("x_select", list[str], examples=[[]], description="The columns to select from the dataset. All are returned if not specified"),
|
|
267
|
+
APIParamFieldInfo("x_offset", int, default=0, description="The number of rows to skip before returning data (applied after data caching)"),
|
|
268
|
+
APIParamFieldInfo("x_limit", int, default=1000, description="The maximum number of rows to return (applied after data caching and offset)"),
|
|
269
|
+
]
|
|
270
|
+
return _get_query_models_helper(widget_parameters, predefined_params)
|
|
271
|
+
|
|
272
|
+
def get_query_models_for_dashboard(widget_parameters: list[str] | None):
|
|
273
|
+
predefined_params = [
|
|
274
|
+
APIParamFieldInfo("x_verify_params", bool, default=False, description="If true, the query parameters are verified to be valid for the dashboard"),
|
|
275
|
+
]
|
|
276
|
+
return _get_query_models_helper(widget_parameters, predefined_params)
|
|
277
|
+
|
|
278
|
+
def get_query_models_for_querying_models():
|
|
279
|
+
predefined_params = [
|
|
280
|
+
APIParamFieldInfo("x_verify_params", bool, default=False, description="If true, the query parameters are verified to be valid"),
|
|
281
|
+
APIParamFieldInfo("x_orientation", str, default="records", description="The orientation of the data to return, one of: 'records', 'rows', or 'columns'"),
|
|
282
|
+
APIParamFieldInfo("x_offset", int, default=0, description="The number of rows to skip before returning data (applied after data caching)"),
|
|
283
|
+
APIParamFieldInfo("x_limit", int, default=1000, description="The maximum number of rows to return (applied after data caching and offset)"),
|
|
284
|
+
APIParamFieldInfo("x_sql_query", str, description="The SQL query to execute on the data models"),
|
|
285
|
+
]
|
|
286
|
+
return _get_query_models_helper(None, predefined_params)
|
|
287
|
+
|
|
219
288
|
def _get_section_from_request_path(request: Request, section: int) -> str:
|
|
220
289
|
url_path: str = request.scope['route'].path
|
|
221
290
|
return url_path.split('/')[section]
|
|
@@ -228,68 +297,245 @@ class ApiServer:
|
|
|
228
297
|
dashboard_raw = _get_section_from_request_path(request, section)
|
|
229
298
|
return u.normalize_name(dashboard_raw)
|
|
230
299
|
|
|
231
|
-
|
|
232
|
-
|
|
300
|
+
expiry_mins = self.env_vars.get(c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES, 30)
|
|
301
|
+
try:
|
|
302
|
+
expiry_mins = int(expiry_mins)
|
|
303
|
+
except ValueError:
|
|
304
|
+
raise ConfigurationError(f"Value for environment variable {c.SQRL_AUTH_TOKEN_EXPIRE_MINUTES} is not an integer, got: {expiry_mins}")
|
|
305
|
+
|
|
306
|
+
# Project Metadata API
|
|
307
|
+
|
|
308
|
+
@app.get(project_metadata_path, tags=["Project Metadata"], response_class=JSONResponse)
|
|
309
|
+
async def get_project_metadata(request: Request) -> arm.ProjectModel:
|
|
310
|
+
return arm.ProjectModel(
|
|
311
|
+
name=project_name,
|
|
312
|
+
version=project_version,
|
|
313
|
+
label=self.manifest_cfg.project_variables.label,
|
|
314
|
+
description=self.manifest_cfg.project_variables.description,
|
|
315
|
+
squirrels_version=__version__
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Authentication
|
|
319
|
+
login_path = project_metadata_path + '/login'
|
|
233
320
|
|
|
234
|
-
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=
|
|
321
|
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=login_path, auto_error=False)
|
|
235
322
|
|
|
236
|
-
|
|
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:
|
|
323
|
+
async def get_current_user(response: Response, token: str = Depends(oauth2_scheme)) -> BaseUser | None:
|
|
247
324
|
user = self.authenticator.get_user_from_token(token)
|
|
248
325
|
username = "" if user is None else user.username
|
|
249
326
|
response.headers["Applied-Username"] = username
|
|
250
327
|
return user
|
|
251
328
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
329
|
+
## Login API
|
|
330
|
+
@app.post(login_path, tags=["Authentication"])
|
|
331
|
+
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()) -> arm.LoginReponse:
|
|
332
|
+
user = self.authenticator.get_user(form_data.username, form_data.password)
|
|
333
|
+
access_token, expiry = self.authenticator.create_access_token(user, expiry_minutes=expiry_mins)
|
|
334
|
+
return arm.LoginReponse(access_token=access_token, token_type="bearer", username=user.username, is_admin=user.is_admin, expiry_time=expiry)
|
|
335
|
+
|
|
336
|
+
## Change Password API
|
|
337
|
+
change_password_path = project_metadata_path + '/change-password'
|
|
338
|
+
|
|
339
|
+
class ChangePasswordRequest(BaseModel):
|
|
340
|
+
old_password: str
|
|
341
|
+
new_password: str
|
|
342
|
+
|
|
343
|
+
@app.put(change_password_path, description="Change the password for the current user", tags=["Authentication"])
|
|
344
|
+
async def change_password(request: ChangePasswordRequest, user: BaseUser | None = Depends(get_current_user)) -> None:
|
|
345
|
+
if user is None:
|
|
346
|
+
raise InvalidInputError(1, "Invalid authorization token")
|
|
347
|
+
self.authenticator.change_password(user.username, request.old_password, request.new_password)
|
|
348
|
+
|
|
349
|
+
## Token API
|
|
350
|
+
tokens_path = project_metadata_path + '/tokens'
|
|
351
|
+
|
|
352
|
+
class TokenRequestBody(BaseModel):
|
|
353
|
+
title: str | None = Field(default=None, description=f"The title of the token. If not provided, a temporary token is created (expiring in {expiry_mins} minutes) and cannot be revoked")
|
|
354
|
+
expiry_minutes: int | None = Field(
|
|
355
|
+
default=None,
|
|
356
|
+
description=f"The number of minutes the token is valid for (or indefinitely if not provided). Ignored and set to {expiry_mins} minutes if title is not provided."
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
@app.post(tokens_path, description="Create a new token for the user", tags=["Authentication"])
|
|
360
|
+
async def create_token(body: TokenRequestBody, user: BaseUser | None = Depends(get_current_user)) -> arm.LoginReponse:
|
|
361
|
+
if user is None:
|
|
362
|
+
raise InvalidInputError(1, "Invalid authorization token")
|
|
363
|
+
|
|
364
|
+
if body.title is None:
|
|
365
|
+
expiry_minutes = expiry_mins
|
|
366
|
+
else:
|
|
367
|
+
expiry_minutes = body.expiry_minutes
|
|
368
|
+
|
|
369
|
+
access_token, expiry = self.authenticator.create_access_token(user, expiry_minutes=expiry_minutes, title=body.title)
|
|
370
|
+
return arm.LoginReponse(access_token=access_token, token_type="bearer", username=user.username, is_admin=user.is_admin, expiry_time=expiry)
|
|
371
|
+
|
|
372
|
+
## Get All Tokens API
|
|
373
|
+
@app.get(tokens_path, description="Get all tokens with title for the current user", tags=["Authentication"])
|
|
374
|
+
async def get_all_tokens(user: BaseUser | None = Depends(get_current_user)) -> list[AccessToken]:
|
|
375
|
+
if user is None:
|
|
376
|
+
raise InvalidInputError(1, "Invalid authorization token")
|
|
377
|
+
return self.authenticator.get_all_tokens(user.username)
|
|
378
|
+
|
|
379
|
+
## Revoke Token API
|
|
380
|
+
revoke_token_path = project_metadata_path + '/tokens/{token_id}'
|
|
381
|
+
|
|
382
|
+
@app.delete(revoke_token_path, description="Revoke a token", tags=["Authentication"])
|
|
383
|
+
async def revoke_token(token_id: str, user: BaseUser | None = Depends(get_current_user)) -> None:
|
|
384
|
+
if user is None:
|
|
385
|
+
raise InvalidInputError(1, "Invalid authorization token")
|
|
386
|
+
self.authenticator.revoke_token(user.username, token_id)
|
|
387
|
+
|
|
388
|
+
## Get Authenticated User Fields From Token API
|
|
389
|
+
get_me_path = project_metadata_path + '/me'
|
|
390
|
+
|
|
391
|
+
fields_without_username = {
|
|
392
|
+
k: (v.annotation, v.default)
|
|
393
|
+
for k, v in self.authenticator.User.model_fields.items()
|
|
394
|
+
if k != "username"
|
|
395
|
+
}
|
|
396
|
+
UserModel = create_model("UserModel", __base__=BaseModel, **fields_without_username) # type: ignore
|
|
397
|
+
|
|
398
|
+
class UserWithoutUsername(UserModel):
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
class UserWithUsername(UserModel):
|
|
402
|
+
username: str
|
|
403
|
+
|
|
404
|
+
class AddUserRequestBody(UserWithUsername):
|
|
405
|
+
password: str
|
|
406
|
+
|
|
407
|
+
@app.get(get_me_path, description="Get the authenticated user's fields", tags=["Authentication"])
|
|
408
|
+
async def get_me(user: BaseUser | None = Depends(get_current_user)) -> UserWithUsername:
|
|
409
|
+
if user is None:
|
|
410
|
+
raise InvalidInputError(1, "Invalid authorization token")
|
|
411
|
+
return UserWithUsername(**user.model_dump(mode='json'))
|
|
412
|
+
|
|
413
|
+
# User Management
|
|
414
|
+
|
|
415
|
+
## User Fields API
|
|
416
|
+
user_fields_path = project_metadata_path + '/user-fields'
|
|
417
|
+
|
|
418
|
+
@app.get(user_fields_path, description="Get details of the user fields", tags=["User Management"])
|
|
419
|
+
async def get_user_fields() -> list[UserField]:
|
|
420
|
+
return self.authenticator.user_fields
|
|
421
|
+
|
|
422
|
+
## Add User API
|
|
423
|
+
add_user_path = project_metadata_path + '/users'
|
|
424
|
+
|
|
425
|
+
@app.post(add_user_path, description="Add a new user by providing details for username, password, and user fields", tags=["User Management"])
|
|
426
|
+
async def add_user(
|
|
427
|
+
new_user: AddUserRequestBody, user: BaseUser | None = Depends(get_current_user)
|
|
428
|
+
) -> None:
|
|
429
|
+
if user is None or not user.is_admin:
|
|
430
|
+
raise InvalidInputError(20, "Authorized user is forbidden to add new users")
|
|
431
|
+
self.authenticator.add_user(new_user.username, new_user.model_dump(mode='json', exclude={"username"}))
|
|
432
|
+
|
|
433
|
+
## Update User API
|
|
434
|
+
update_user_path = project_metadata_path + '/users/{username}'
|
|
435
|
+
|
|
436
|
+
@app.put(update_user_path, description="Update the user of the given username given the new user details", tags=["User Management"])
|
|
437
|
+
async def update_user(
|
|
438
|
+
username: str, updated_user: UserWithoutUsername, user: BaseUser | None = Depends(get_current_user)
|
|
439
|
+
) -> None:
|
|
440
|
+
if user is None or not user.is_admin:
|
|
441
|
+
raise InvalidInputError(20, "Authorized user is forbidden to update users")
|
|
442
|
+
self.authenticator.add_user(username, updated_user.model_dump(mode='json'), update_user=True)
|
|
443
|
+
|
|
444
|
+
## List Users API
|
|
445
|
+
list_users_path = project_metadata_path + '/users'
|
|
446
|
+
|
|
447
|
+
@app.get(list_users_path, tags=["User Management"])
|
|
448
|
+
async def list_all_users() -> list[UserWithUsername]:
|
|
449
|
+
return self.authenticator.get_all_users()
|
|
450
|
+
|
|
451
|
+
## Delete User API
|
|
452
|
+
delete_user_path = project_metadata_path + '/users/{username}'
|
|
453
|
+
|
|
454
|
+
@app.delete(delete_user_path, tags=["User Management"])
|
|
455
|
+
async def delete_user(username: str, user: BaseUser | None = Depends(get_current_user)) -> None:
|
|
456
|
+
if user is None or not user.is_admin:
|
|
457
|
+
raise InvalidInputError(21, "Authorized user is forbidden to delete users")
|
|
458
|
+
if username == user.username:
|
|
459
|
+
raise InvalidInputError(22, "Cannot delete your own user")
|
|
460
|
+
self.authenticator.delete_user(username)
|
|
461
|
+
|
|
462
|
+
# Data Catalog API
|
|
463
|
+
data_catalog_path = project_metadata_path + '/data-catalog'
|
|
464
|
+
|
|
465
|
+
dataset_results_path = project_metadata_path + '/dataset/{dataset}'
|
|
255
466
|
dataset_parameters_path = dataset_results_path + '/parameters'
|
|
256
|
-
|
|
467
|
+
|
|
468
|
+
dashboard_results_path = project_metadata_path + '/dashboard/{dashboard}'
|
|
257
469
|
dashboard_parameters_path = dashboard_results_path + '/parameters'
|
|
258
470
|
|
|
259
|
-
def get_data_catalog0(user:
|
|
471
|
+
async def get_data_catalog0(user: BaseUser | None) -> arm.CatalogModel:
|
|
472
|
+
parameters = self.param_cfg_set.apply_selections(None, {}, user)
|
|
473
|
+
parameters_model = parameters.to_api_response_model0()
|
|
474
|
+
full_parameters_list = [p.name for p in parameters_model.parameters]
|
|
475
|
+
|
|
260
476
|
dataset_items: list[arm.DatasetItemModel] = []
|
|
261
477
|
for name, config in self.manifest_cfg.datasets.items():
|
|
262
478
|
if self.authenticator.can_user_access_scope(user, config.scope):
|
|
263
479
|
name_normalized = u.normalize_name_for_api(name)
|
|
480
|
+
metadata = self.project.dataset_metadata(name).to_json()
|
|
481
|
+
parameters = config.parameters if config.parameters is not None else full_parameters_list
|
|
264
482
|
dataset_items.append(arm.DatasetItemModel(
|
|
265
|
-
name=
|
|
483
|
+
name=name_normalized, label=config.label,
|
|
484
|
+
description=config.description,
|
|
485
|
+
schema=metadata["schema"], # type: ignore
|
|
486
|
+
parameters=parameters,
|
|
266
487
|
parameters_path=dataset_parameters_path.format(dataset=name_normalized),
|
|
267
488
|
result_path=dataset_results_path.format(dataset=name_normalized)
|
|
268
489
|
))
|
|
269
490
|
|
|
270
491
|
dashboard_items: list[arm.DashboardItemModel] = []
|
|
271
|
-
for name,
|
|
492
|
+
for name, dashboard in self.dashboards.items():
|
|
493
|
+
config = dashboard.config
|
|
272
494
|
if self.authenticator.can_user_access_scope(user, config.scope):
|
|
273
495
|
name_normalized = u.normalize_name_for_api(name)
|
|
274
496
|
|
|
275
497
|
try:
|
|
276
498
|
dashboard_format = self.dashboards[name].get_dashboard_format()
|
|
277
499
|
except KeyError:
|
|
278
|
-
raise
|
|
500
|
+
raise ConfigurationError(f"No dashboard file found for: {name}")
|
|
279
501
|
|
|
502
|
+
parameters = config.parameters if config.parameters is not None else full_parameters_list
|
|
280
503
|
dashboard_items.append(arm.DashboardItemModel(
|
|
281
|
-
name=name, label=config.label,
|
|
504
|
+
name=name, label=config.label,
|
|
505
|
+
description=config.description,
|
|
506
|
+
result_format=dashboard_format,
|
|
507
|
+
parameters=parameters,
|
|
282
508
|
parameters_path=dashboard_parameters_path.format(dashboard=name_normalized),
|
|
283
509
|
result_path=dashboard_results_path.format(dashboard=name_normalized)
|
|
284
510
|
))
|
|
285
511
|
|
|
286
|
-
|
|
512
|
+
if user and user.is_admin:
|
|
513
|
+
compiled_dag = await self.project._get_compiled_dag(user=user)
|
|
514
|
+
connections_items = self.project._get_all_connections()
|
|
515
|
+
data_models = self.project._get_all_data_models(compiled_dag)
|
|
516
|
+
lineage_items = self.project._get_all_data_lineage(compiled_dag)
|
|
517
|
+
else:
|
|
518
|
+
connections_items = []
|
|
519
|
+
data_models = []
|
|
520
|
+
lineage_items = []
|
|
521
|
+
|
|
522
|
+
return arm.CatalogModel(
|
|
523
|
+
parameters=parameters_model.parameters,
|
|
524
|
+
datasets=dataset_items,
|
|
525
|
+
dashboards=dashboard_items,
|
|
526
|
+
connections=connections_items,
|
|
527
|
+
models=data_models,
|
|
528
|
+
lineage=lineage_items,
|
|
529
|
+
)
|
|
287
530
|
|
|
288
|
-
@app.get(data_catalog_path, tags=["
|
|
289
|
-
def
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
531
|
+
@app.get(data_catalog_path, tags=["Project Metadata"], summary="Get catalog of datasets and dashboards available for user")
|
|
532
|
+
async def get_data_catalog(request: Request, user: BaseUser | None = Depends(get_current_user)) -> arm.CatalogModel:
|
|
533
|
+
"""
|
|
534
|
+
Get catalog of datasets and dashboards available for the authenticated user.
|
|
535
|
+
|
|
536
|
+
For admin users, this endpoint will also return detailed information about all models and their lineage in the project.
|
|
537
|
+
"""
|
|
538
|
+
return await get_data_catalog0(user)
|
|
293
539
|
|
|
294
540
|
# Parameters API Helpers
|
|
295
541
|
parameters_description = "Selections of one parameter may cascade the available options in another parameter. " \
|
|
@@ -298,99 +544,145 @@ class ApiServer:
|
|
|
298
544
|
"selection to this endpoint whenever it changes to refresh the parameter options of children parameters."
|
|
299
545
|
|
|
300
546
|
async def get_parameters_helper(
|
|
301
|
-
parameters_tuple: tuple[str, ...]
|
|
547
|
+
parameters_tuple: tuple[str, ...] | None, entity_type: str, entity_name: str, entity_scope: PermissionScope,
|
|
548
|
+
user: BaseUser | None, selections: tuple[tuple[str, Any], ...]
|
|
302
549
|
) -> ParameterSet:
|
|
303
|
-
|
|
304
|
-
|
|
550
|
+
selections_dict = dict(selections)
|
|
551
|
+
if "x_parent_param" not in selections_dict:
|
|
552
|
+
if len(selections_dict) > 1:
|
|
553
|
+
raise InvalidInputError(202, f"The parameters endpoint takes at most 1 widget parameter selection (unless x_parent_param is provided). Got {selections_dict}")
|
|
554
|
+
elif len(selections_dict) == 1:
|
|
555
|
+
parent_param = next(iter(selections_dict))
|
|
556
|
+
selections_dict["x_parent_param"] = parent_param
|
|
305
557
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
558
|
+
parent_param = selections_dict.get("x_parent_param")
|
|
559
|
+
if parent_param is not None and parent_param not in selections_dict:
|
|
560
|
+
# this condition is possible for multi-select parameters with empty selection
|
|
561
|
+
selections_dict[parent_param] = list()
|
|
562
|
+
|
|
563
|
+
if not self.authenticator.can_user_access_scope(user, entity_scope):
|
|
564
|
+
raise self.project._permission_error(user, entity_type, entity_name, entity_scope.name)
|
|
565
|
+
|
|
566
|
+
param_set = self.param_cfg_set.apply_selections(parameters_tuple, selections_dict, user, parent_param=parent_param)
|
|
309
567
|
return param_set
|
|
310
568
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
parameters_cache_ttl = settings.get(c.PARAMETERS_CACHE_TTL_SETTING, 60)
|
|
569
|
+
parameters_cache_size = int(self.env_vars.get(c.SQRL_PARAMETERS_CACHE_SIZE, 1024))
|
|
570
|
+
parameters_cache_ttl = int(self.env_vars.get(c.SQRL_PARAMETERS_CACHE_TTL_MINUTES, 60))
|
|
314
571
|
params_cache = TTLCache(maxsize=parameters_cache_size, ttl=parameters_cache_ttl*60)
|
|
315
572
|
|
|
316
573
|
async def get_parameters_cachable(
|
|
317
|
-
parameters_tuple: tuple[str, ...]
|
|
574
|
+
parameters_tuple: tuple[str, ...] | None, entity_type: str, entity_name: str, entity_scope: PermissionScope,
|
|
575
|
+
user: BaseUser | None, selections: tuple[tuple[str, Any], ...]
|
|
318
576
|
) -> ParameterSet:
|
|
319
|
-
return await do_cachable_action(params_cache, get_parameters_helper, parameters_tuple, user, selections
|
|
577
|
+
return await do_cachable_action(params_cache, get_parameters_helper, parameters_tuple, entity_type, entity_name, entity_scope, user, selections)
|
|
320
578
|
|
|
321
579
|
async def get_parameters_definition(
|
|
322
|
-
parameters_list: list[str]
|
|
580
|
+
parameters_list: list[str] | None, entity_type: str, entity_name: str, entity_scope: PermissionScope,
|
|
581
|
+
user: BaseUser | None, all_request_params: dict, params: Mapping
|
|
323
582
|
) -> arm.ParametersModel:
|
|
324
|
-
|
|
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()
|
|
583
|
+
self._validate_request_params(all_request_params, params)
|
|
332
584
|
|
|
333
|
-
|
|
585
|
+
get_parameters_function = get_parameters_helper if self.no_cache else get_parameters_cachable
|
|
586
|
+
selections = get_selections_as_immutable(params, uncached_keys={"x_verify_params"})
|
|
587
|
+
parameters_tuple = tuple(parameters_list) if parameters_list is not None else None
|
|
588
|
+
result = await get_parameters_function(parameters_tuple, entity_type, entity_name, entity_scope, user, selections)
|
|
589
|
+
return result.to_api_response_model0()
|
|
590
|
+
|
|
591
|
+
def validate_parameters_list(parameters: list[str] | None, entity_type: str) -> None:
|
|
592
|
+
if parameters is None:
|
|
593
|
+
return
|
|
334
594
|
for param in parameters:
|
|
335
595
|
if param not in param_fields:
|
|
336
596
|
all_params = list(param_fields.keys())
|
|
337
|
-
raise
|
|
338
|
-
|
|
597
|
+
raise ConfigurationError(
|
|
598
|
+
f"{entity_type} '{dataset_name}' use parameter '{param}' which doesn't exist. Available parameters are:"
|
|
599
|
+
f"\n {all_params}"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Project-Level Parameters API
|
|
603
|
+
project_level_parameters_path = project_metadata_path + '/parameters'
|
|
604
|
+
|
|
605
|
+
QueryModelForGetProjectParams, QueryModelForPostProjectParams = get_query_models_for_parameters(None)
|
|
606
|
+
|
|
607
|
+
@app.get(project_level_parameters_path, tags=["Project Metadata"], description=parameters_description)
|
|
608
|
+
async def get_project_parameters(
|
|
609
|
+
request: Request, params: QueryModelForGetProjectParams, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
610
|
+
) -> arm.ParametersModel:
|
|
611
|
+
start = time.time()
|
|
612
|
+
result = await get_parameters_definition(
|
|
613
|
+
None, "project", "", PermissionScope.PUBLIC, user, dict(request.query_params), asdict(params)
|
|
614
|
+
)
|
|
615
|
+
self.logger.log_activity_time("GET REQUEST for PROJECT PARAMETERS", start, request_id=_get_request_id(request))
|
|
616
|
+
return result
|
|
617
|
+
|
|
618
|
+
@app.post(project_level_parameters_path, tags=["Project Metadata"], description=parameters_description)
|
|
619
|
+
async def get_project_parameters_with_post(
|
|
620
|
+
request: Request, params: QueryModelForPostProjectParams, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
621
|
+
) -> arm.ParametersModel:
|
|
622
|
+
start = time.time()
|
|
623
|
+
params_model: BaseModel = params
|
|
624
|
+
payload: dict = await request.json()
|
|
625
|
+
result = await get_parameters_definition(
|
|
626
|
+
None, "project", "", PermissionScope.PUBLIC, user, payload, params_model.model_dump()
|
|
627
|
+
)
|
|
628
|
+
self.logger.log_activity_time("POST REQUEST for PROJECT PARAMETERS", start, request_id=_get_request_id(request))
|
|
629
|
+
return result
|
|
339
630
|
|
|
340
631
|
# Dataset Results API Helpers
|
|
341
632
|
async def get_dataset_results_helper(
|
|
342
|
-
dataset: str, user:
|
|
343
|
-
) ->
|
|
344
|
-
|
|
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
|
|
633
|
+
dataset: str, user: BaseUser | None, selections: tuple[tuple[str, Any], ...]
|
|
634
|
+
) -> DatasetResult:
|
|
635
|
+
return await self.project.dataset(dataset, selections=dict(selections), user=user)
|
|
348
636
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
dataset_results_cache_ttl = settings.get(c.DATASETS_CACHE_TTL_SETTING, settings.get(c.RESULTS_CACHE_TTL_SETTING, 60))
|
|
637
|
+
dataset_results_cache_size = int(self.env_vars.get(c.SQRL_DATASETS_CACHE_SIZE, 128))
|
|
638
|
+
dataset_results_cache_ttl = int(self.env_vars.get(c.SQRL_DATASETS_CACHE_TTL_MINUTES, 60))
|
|
352
639
|
dataset_results_cache = TTLCache(maxsize=dataset_results_cache_size, ttl=dataset_results_cache_ttl*60)
|
|
353
640
|
|
|
354
641
|
async def get_dataset_results_cachable(
|
|
355
|
-
dataset: str, user:
|
|
356
|
-
) ->
|
|
357
|
-
return await do_cachable_action(dataset_results_cache, get_dataset_results_helper, dataset, user, selections
|
|
642
|
+
dataset: str, user: BaseUser | None, selections: tuple[tuple[str, Any], ...]
|
|
643
|
+
) -> DatasetResult:
|
|
644
|
+
return await do_cachable_action(dataset_results_cache, get_dataset_results_helper, dataset, user, selections)
|
|
358
645
|
|
|
359
646
|
async def get_dataset_results_definition(
|
|
360
|
-
dataset_name: str, user:
|
|
647
|
+
dataset_name: str, user: BaseUser | None, all_request_params: dict, params: Mapping
|
|
361
648
|
) -> arm.DatasetResultModel:
|
|
649
|
+
self._validate_request_params(all_request_params, params)
|
|
650
|
+
|
|
362
651
|
get_dataset_function = get_dataset_results_helper if self.no_cache else get_dataset_results_cachable
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
652
|
+
uncached_keys = {"x_verify_params", "x_orientation", "x_select", "x_limit", "x_offset"}
|
|
653
|
+
selections = get_selections_as_immutable(params, uncached_keys)
|
|
654
|
+
result = await get_dataset_function(dataset_name, user, selections)
|
|
655
|
+
|
|
656
|
+
orientation = params.get("x_orientation", "records")
|
|
657
|
+
raw_select = params.get("x_select")
|
|
658
|
+
select = tuple(raw_select) if raw_select is not None else tuple()
|
|
659
|
+
limit = params.get("x_limit", 1000)
|
|
660
|
+
offset = params.get("x_offset", 0)
|
|
661
|
+
return arm.DatasetResultModel(**result.to_json(orientation, select, limit, offset))
|
|
368
662
|
|
|
369
663
|
# Dashboard Results API Helpers
|
|
370
664
|
async def get_dashboard_results_helper(
|
|
371
|
-
dashboard: str, user:
|
|
665
|
+
dashboard: str, user: BaseUser | None, selections: tuple[tuple[str, Any], ...]
|
|
372
666
|
) -> Dashboard:
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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)
|
|
667
|
+
return await self.project.dashboard(dashboard, selections=dict(selections), user=user)
|
|
668
|
+
|
|
669
|
+
dashboard_results_cache_size = int(self.env_vars.get(c.SQRL_DASHBOARDS_CACHE_SIZE, 128))
|
|
670
|
+
dashboard_results_cache_ttl = int(self.env_vars.get(c.SQRL_DASHBOARDS_CACHE_TTL_MINUTES, 60))
|
|
381
671
|
dashboard_results_cache = TTLCache(maxsize=dashboard_results_cache_size, ttl=dashboard_results_cache_ttl*60)
|
|
382
672
|
|
|
383
673
|
async def get_dashboard_results_cachable(
|
|
384
|
-
dashboard: str, user:
|
|
674
|
+
dashboard: str, user: BaseUser | None, selections: tuple[tuple[str, Any], ...]
|
|
385
675
|
) -> Dashboard:
|
|
386
|
-
return await do_cachable_action(dashboard_results_cache, get_dashboard_results_helper, dashboard, user, selections
|
|
676
|
+
return await do_cachable_action(dashboard_results_cache, get_dashboard_results_helper, dashboard, user, selections)
|
|
387
677
|
|
|
388
678
|
async def get_dashboard_results_definition(
|
|
389
|
-
dashboard_name: str, user:
|
|
679
|
+
dashboard_name: str, user: BaseUser | None, all_request_params: dict, params: Mapping
|
|
390
680
|
) -> Response:
|
|
681
|
+
self._validate_request_params(all_request_params, params)
|
|
682
|
+
|
|
391
683
|
get_dashboard_function = get_dashboard_results_helper if self.no_cache else get_dashboard_results_cachable
|
|
392
|
-
selections
|
|
393
|
-
dashboard_obj = await get_dashboard_function(dashboard_name, user, selections
|
|
684
|
+
selections = get_selections_as_immutable(params, uncached_keys={"x_verify_params"})
|
|
685
|
+
dashboard_obj = await get_dashboard_function(dashboard_name, user, selections)
|
|
394
686
|
if dashboard_obj._format == c.PNG:
|
|
395
687
|
assert isinstance(dashboard_obj._content, bytes)
|
|
396
688
|
result = Response(dashboard_obj._content, media_type="image/png")
|
|
@@ -408,145 +700,205 @@ class ApiServer:
|
|
|
408
700
|
|
|
409
701
|
validate_parameters_list(dataset_config.parameters, "Dataset")
|
|
410
702
|
|
|
411
|
-
|
|
703
|
+
QueryModelForGetParams, QueryModelForPostParams = get_query_models_for_parameters(dataset_config.parameters)
|
|
704
|
+
QueryModelForGetDataset, QueryModelForPostDataset = get_query_models_for_dataset(dataset_config.parameters)
|
|
412
705
|
|
|
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
|
-
)
|
|
706
|
+
@app.get(curr_parameters_path, tags=[f"Dataset '{dataset_name}'"], description=parameters_description, response_class=JSONResponse)
|
|
417
707
|
async def get_dataset_parameters(
|
|
418
|
-
request: Request, params:
|
|
708
|
+
request: Request, params: QueryModelForGetParams, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
419
709
|
) -> arm.ParametersModel:
|
|
420
710
|
start = time.time()
|
|
421
711
|
curr_dataset_name = get_dataset_name(request, -2)
|
|
422
712
|
parameters_list = self.manifest_cfg.datasets[curr_dataset_name].parameters
|
|
423
|
-
|
|
713
|
+
scope = self.manifest_cfg.datasets[curr_dataset_name].scope
|
|
714
|
+
result = await get_parameters_definition(
|
|
715
|
+
parameters_list, "dataset", curr_dataset_name, scope, user, dict(request.query_params), asdict(params)
|
|
716
|
+
)
|
|
424
717
|
self.logger.log_activity_time("GET REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
425
718
|
return result
|
|
426
719
|
|
|
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
|
-
)
|
|
720
|
+
@app.post(curr_parameters_path, tags=[f"Dataset '{dataset_name}'"], description=parameters_description, response_class=JSONResponse)
|
|
431
721
|
async def get_dataset_parameters_with_post(
|
|
432
|
-
request: Request, params:
|
|
722
|
+
request: Request, params: QueryModelForPostParams, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
433
723
|
) -> arm.ParametersModel:
|
|
434
724
|
start = time.time()
|
|
435
725
|
curr_dataset_name = get_dataset_name(request, -2)
|
|
436
726
|
parameters_list = self.manifest_cfg.datasets[curr_dataset_name].parameters
|
|
727
|
+
scope = self.manifest_cfg.datasets[curr_dataset_name].scope
|
|
437
728
|
params: BaseModel = params
|
|
438
|
-
|
|
729
|
+
payload: dict = await request.json()
|
|
730
|
+
result = await get_parameters_definition(
|
|
731
|
+
parameters_list, "dataset", curr_dataset_name, scope, user, payload, params.model_dump()
|
|
732
|
+
)
|
|
439
733
|
self.logger.log_activity_time("POST REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
440
734
|
return result
|
|
441
735
|
|
|
442
736
|
@app.get(curr_results_path, tags=[f"Dataset '{dataset_name}'"], description=dataset_config.description, response_class=JSONResponse)
|
|
443
737
|
async def get_dataset_results(
|
|
444
|
-
request: Request, params:
|
|
738
|
+
request: Request, params: QueryModelForGetDataset, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
445
739
|
) -> arm.DatasetResultModel:
|
|
446
740
|
start = time.time()
|
|
447
741
|
curr_dataset_name = get_dataset_name(request, -1)
|
|
448
|
-
result = await get_dataset_results_definition(curr_dataset_name, user, request.
|
|
742
|
+
result = await get_dataset_results_definition(curr_dataset_name, user, dict(request.query_params), asdict(params))
|
|
449
743
|
self.logger.log_activity_time("GET REQUEST for DATASET RESULTS", start, request_id=_get_request_id(request))
|
|
450
744
|
return result
|
|
451
745
|
|
|
452
746
|
@app.post(curr_results_path, tags=[f"Dataset '{dataset_name}'"], description=dataset_config.description, response_class=JSONResponse)
|
|
453
747
|
async def get_dataset_results_with_post(
|
|
454
|
-
request: Request, params:
|
|
748
|
+
request: Request, params: QueryModelForPostDataset, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
455
749
|
) -> arm.DatasetResultModel:
|
|
456
750
|
start = time.time()
|
|
457
751
|
curr_dataset_name = get_dataset_name(request, -1)
|
|
458
752
|
params: BaseModel = params
|
|
459
|
-
|
|
753
|
+
payload: dict = await request.json()
|
|
754
|
+
result = await get_dataset_results_definition(curr_dataset_name, user, payload, params.model_dump())
|
|
460
755
|
self.logger.log_activity_time("POST REQUEST for DATASET RESULTS", start, request_id=_get_request_id(request))
|
|
461
756
|
return result
|
|
462
757
|
|
|
463
758
|
# Dashboard Parameters and Results APIs
|
|
464
|
-
for dashboard_name,
|
|
759
|
+
for dashboard_name, dashboard in self.dashboards.items():
|
|
465
760
|
dashboard_normalized = u.normalize_name_for_api(dashboard_name)
|
|
466
761
|
curr_parameters_path = dashboard_parameters_path.format(dashboard=dashboard_normalized)
|
|
467
762
|
curr_results_path = dashboard_results_path.format(dashboard=dashboard_normalized)
|
|
468
763
|
|
|
469
|
-
validate_parameters_list(
|
|
764
|
+
validate_parameters_list(dashboard.config.parameters, "Dashboard")
|
|
470
765
|
|
|
471
|
-
|
|
766
|
+
QueryModelForGetParams, QueryModelForPostParams = get_query_models_for_parameters(dashboard.config.parameters)
|
|
767
|
+
QueryModelForGetDash, QueryModelForPostDash = get_query_models_for_dashboard(dashboard.config.parameters)
|
|
472
768
|
|
|
473
769
|
@app.get(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=parameters_description, response_class=JSONResponse)
|
|
474
770
|
async def get_dashboard_parameters(
|
|
475
|
-
request: Request, params:
|
|
771
|
+
request: Request, params: QueryModelForGetParams, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
476
772
|
) -> arm.ParametersModel:
|
|
477
773
|
start = time.time()
|
|
478
774
|
curr_dashboard_name = get_dashboard_name(request, -2)
|
|
479
|
-
parameters_list = self.
|
|
480
|
-
|
|
775
|
+
parameters_list = self.dashboards[curr_dashboard_name].config.parameters
|
|
776
|
+
scope = self.dashboards[curr_dashboard_name].config.scope
|
|
777
|
+
result = await get_parameters_definition(
|
|
778
|
+
parameters_list, "dashboard", curr_dashboard_name, scope, user, dict(request.query_params), asdict(params)
|
|
779
|
+
)
|
|
481
780
|
self.logger.log_activity_time("GET REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
482
781
|
return result
|
|
483
782
|
|
|
484
783
|
@app.post(curr_parameters_path, tags=[f"Dashboard '{dashboard_name}'"], description=parameters_description, response_class=JSONResponse)
|
|
485
784
|
async def get_dashboard_parameters_with_post(
|
|
486
|
-
request: Request, params:
|
|
785
|
+
request: Request, params: QueryModelForPostParams, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
487
786
|
) -> arm.ParametersModel:
|
|
488
787
|
start = time.time()
|
|
489
788
|
curr_dashboard_name = get_dashboard_name(request, -2)
|
|
490
|
-
parameters_list = self.
|
|
789
|
+
parameters_list = self.dashboards[curr_dashboard_name].config.parameters
|
|
790
|
+
scope = self.dashboards[curr_dashboard_name].config.scope
|
|
491
791
|
params: BaseModel = params
|
|
492
|
-
|
|
792
|
+
payload: dict = await request.json()
|
|
793
|
+
result = await get_parameters_definition(
|
|
794
|
+
parameters_list, "dashboard", curr_dashboard_name, scope, user, payload, params.model_dump()
|
|
795
|
+
)
|
|
493
796
|
self.logger.log_activity_time("POST REQUEST for PARAMETERS", start, request_id=_get_request_id(request))
|
|
494
797
|
return result
|
|
495
798
|
|
|
496
|
-
@app.get(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=
|
|
799
|
+
@app.get(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
|
|
497
800
|
async def get_dashboard_results(
|
|
498
|
-
request: Request, params:
|
|
801
|
+
request: Request, params: QueryModelForGetDash, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
499
802
|
) -> Response:
|
|
500
803
|
start = time.time()
|
|
501
804
|
curr_dashboard_name = get_dashboard_name(request, -1)
|
|
502
|
-
result = await get_dashboard_results_definition(curr_dashboard_name, user, request.
|
|
805
|
+
result = await get_dashboard_results_definition(curr_dashboard_name, user, dict(request.query_params), asdict(params))
|
|
503
806
|
self.logger.log_activity_time("GET REQUEST for DASHBOARD RESULTS", start, request_id=_get_request_id(request))
|
|
504
807
|
return result
|
|
505
808
|
|
|
506
|
-
@app.post(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=
|
|
809
|
+
@app.post(curr_results_path, tags=[f"Dashboard '{dashboard_name}'"], description=dashboard.config.description, response_class=Response)
|
|
507
810
|
async def get_dashboard_results_with_post(
|
|
508
|
-
request: Request, params:
|
|
811
|
+
request: Request, params: QueryModelForPostDash, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
509
812
|
) -> Response:
|
|
510
813
|
start = time.time()
|
|
511
814
|
curr_dashboard_name = get_dashboard_name(request, -1)
|
|
512
815
|
params: BaseModel = params
|
|
513
|
-
|
|
816
|
+
payload: dict = await request.json()
|
|
817
|
+
result = await get_dashboard_results_definition(curr_dashboard_name, user, payload, params.model_dump())
|
|
514
818
|
self.logger.log_activity_time("POST REQUEST for DASHBOARD RESULTS", start, request_id=_get_request_id(request))
|
|
515
819
|
return result
|
|
516
820
|
|
|
517
|
-
# Project
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
minor_versions=[0],
|
|
525
|
-
token_path=token_path,
|
|
526
|
-
data_catalog_path=data_catalog_path
|
|
527
|
-
)]
|
|
528
|
-
)
|
|
821
|
+
# Build Project API
|
|
822
|
+
@app.post(project_metadata_path + '/build', tags=["Data Management"], summary="Build or update the virtual data environment for the project")
|
|
823
|
+
async def build(user: BaseUser | None = Depends(get_current_user)): # type: ignore
|
|
824
|
+
if not self.authenticator.can_user_access_scope(user, PermissionScope.PRIVATE):
|
|
825
|
+
raise InvalidInputError(26, f"User '{user}' does not have permission to build the virtual data environment")
|
|
826
|
+
await self.project.build(stage_file=True)
|
|
827
|
+
return Response(status_code=status.HTTP_200_OK)
|
|
529
828
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
829
|
+
# Query Models API
|
|
830
|
+
query_models_path = project_metadata_path + '/query-models'
|
|
831
|
+
QueryModelForQueryModels, QueryModelForPostQueryModels = get_query_models_for_querying_models()
|
|
832
|
+
|
|
833
|
+
async def query_models_helper(
|
|
834
|
+
sql_query: str, user: BaseUser | None, selections: tuple[tuple[str, Any], ...]
|
|
835
|
+
) -> DatasetResult:
|
|
836
|
+
return await self.project.query_models(sql_query, selections=dict(selections), user=user)
|
|
837
|
+
|
|
838
|
+
async def query_models_cachable(
|
|
839
|
+
sql_query: str, user: BaseUser | None, selections: tuple[tuple[str, Any], ...]
|
|
840
|
+
) -> DatasetResult:
|
|
841
|
+
# Share the same cache for dataset results
|
|
842
|
+
return await do_cachable_action(dataset_results_cache, query_models_helper, sql_query, user, selections)
|
|
843
|
+
|
|
844
|
+
async def query_models_definition(
|
|
845
|
+
user: BaseUser | None, all_request_params: dict, params: Mapping
|
|
846
|
+
) -> arm.DatasetResultModel:
|
|
847
|
+
self._validate_request_params(all_request_params, params)
|
|
542
848
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
849
|
+
if not self.authenticator.can_user_access_scope(user, PermissionScope.PRIVATE):
|
|
850
|
+
raise InvalidInputError(27, f"User '{user}' does not have permission to query data models")
|
|
851
|
+
sql_query = params.get("x_sql_query")
|
|
852
|
+
if sql_query is None:
|
|
853
|
+
raise InvalidInputError(203, "SQL query must be provided")
|
|
854
|
+
|
|
855
|
+
query_models_function = query_models_helper if self.no_cache else query_models_cachable
|
|
856
|
+
uncached_keys = {"x_verify_params", "x_sql_query", "x_orientation", "x_limit", "x_offset"}
|
|
857
|
+
selections = get_selections_as_immutable(params, uncached_keys)
|
|
858
|
+
result = await query_models_function(sql_query, user, selections)
|
|
859
|
+
|
|
860
|
+
orientation = params.get("x_orientation", "records")
|
|
861
|
+
limit = params.get("x_limit", 1000)
|
|
862
|
+
offset = params.get("x_offset", 0)
|
|
863
|
+
return arm.DatasetResultModel(**result.to_json(orientation, tuple(), limit, offset))
|
|
864
|
+
|
|
865
|
+
@app.get(query_models_path, tags=["Data Management"], response_class=JSONResponse)
|
|
866
|
+
async def query_models(
|
|
867
|
+
request: Request, params: QueryModelForQueryModels, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
868
|
+
) -> arm.DatasetResultModel:
|
|
869
|
+
start = time.time()
|
|
870
|
+
result = await query_models_definition(user, dict(request.query_params), asdict(params))
|
|
871
|
+
self.logger.log_activity_time("GET REQUEST for QUERY MODELS", start, request_id=_get_request_id(request))
|
|
872
|
+
return result
|
|
873
|
+
|
|
874
|
+
@app.post(query_models_path, tags=["Data Management"], response_class=JSONResponse)
|
|
875
|
+
async def query_models_with_post(
|
|
876
|
+
request: Request, params: QueryModelForPostQueryModels, user: BaseUser | None = Depends(get_current_user) # type: ignore
|
|
877
|
+
) -> arm.DatasetResultModel:
|
|
878
|
+
start = time.time()
|
|
879
|
+
params: BaseModel = params
|
|
880
|
+
payload: dict = await request.json()
|
|
881
|
+
result = await query_models_definition(user, payload, params.model_dump())
|
|
882
|
+
self.logger.log_activity_time("POST REQUEST for QUERY MODELS", start, request_id=_get_request_id(request))
|
|
883
|
+
return result
|
|
884
|
+
|
|
885
|
+
# Add Root Path Redirection to Squirrels Studio
|
|
886
|
+
full_hostname = f"http://{uvicorn_args.host}:{uvicorn_args.port}"
|
|
887
|
+
encoded_hostname = urllib.parse.quote(full_hostname, safe="")
|
|
888
|
+
squirrels_studio_url = f"https://squirrels-analytics.github.io/squirrels-studio/#/login?host={encoded_hostname}&projectName={project_name}&projectVersion={project_version}"
|
|
889
|
+
|
|
890
|
+
@app.get("/", include_in_schema=False)
|
|
891
|
+
async def redirect_to_studio():
|
|
892
|
+
return RedirectResponse(url=squirrels_studio_url)
|
|
548
893
|
|
|
549
|
-
# Run API
|
|
894
|
+
# Run the API Server
|
|
550
895
|
import uvicorn
|
|
896
|
+
|
|
897
|
+
print("\nWelcome to the Squirrels Data Application!\n")
|
|
898
|
+
print(f"- Application UI: {squirrels_studio_url}")
|
|
899
|
+
print(f"- API Docs (with ReDoc): {full_hostname}{project_metadata_path}/redoc")
|
|
900
|
+
print(f"- API Docs (with Swagger UI): {full_hostname}{project_metadata_path}/docs")
|
|
901
|
+
print()
|
|
902
|
+
|
|
551
903
|
self.logger.log_activity_time("creating app server", start)
|
|
552
|
-
uvicorn.run(app, host=uvicorn_args.host, port=uvicorn_args.port)
|
|
904
|
+
uvicorn.run(app, host=uvicorn_args.host, port=uvicorn_args.port)
|