squirrels 0.4.0__py3-none-any.whl → 0.5.0b1__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.

Files changed (80) hide show
  1. squirrels/__init__.py +10 -6
  2. squirrels/_api_response_models.py +93 -44
  3. squirrels/_api_server.py +571 -219
  4. squirrels/_auth.py +451 -0
  5. squirrels/_command_line.py +61 -20
  6. squirrels/_connection_set.py +38 -25
  7. squirrels/_constants.py +44 -34
  8. squirrels/_dashboards_io.py +34 -16
  9. squirrels/_exceptions.py +57 -0
  10. squirrels/_initializer.py +117 -44
  11. squirrels/_manifest.py +124 -62
  12. squirrels/_model_builder.py +111 -0
  13. squirrels/_model_configs.py +74 -0
  14. squirrels/_model_queries.py +52 -0
  15. squirrels/_models.py +860 -354
  16. squirrels/_package_loader.py +8 -4
  17. squirrels/_parameter_configs.py +45 -65
  18. squirrels/_parameter_sets.py +15 -13
  19. squirrels/_project.py +561 -0
  20. squirrels/_py_module.py +4 -3
  21. squirrels/_seeds.py +35 -16
  22. squirrels/_sources.py +106 -0
  23. squirrels/_utils.py +166 -63
  24. squirrels/_version.py +1 -1
  25. squirrels/arguments/init_time_args.py +78 -15
  26. squirrels/arguments/run_time_args.py +62 -101
  27. squirrels/dashboards.py +4 -4
  28. squirrels/data_sources.py +94 -162
  29. squirrels/dataset_result.py +86 -0
  30. squirrels/dateutils.py +4 -4
  31. squirrels/package_data/base_project/.env +30 -0
  32. squirrels/package_data/base_project/.env.example +30 -0
  33. squirrels/package_data/base_project/.gitignore +3 -2
  34. squirrels/package_data/base_project/assets/expenses.db +0 -0
  35. squirrels/package_data/base_project/connections.yml +11 -3
  36. squirrels/package_data/base_project/dashboards/dashboard_example.py +15 -13
  37. squirrels/package_data/base_project/dashboards/dashboard_example.yml +22 -0
  38. squirrels/package_data/base_project/docker/.dockerignore +5 -2
  39. squirrels/package_data/base_project/docker/Dockerfile +3 -3
  40. squirrels/package_data/base_project/docker/compose.yml +1 -1
  41. squirrels/package_data/base_project/duckdb_init.sql +9 -0
  42. squirrels/package_data/base_project/macros/macros_example.sql +15 -0
  43. squirrels/package_data/base_project/models/builds/build_example.py +26 -0
  44. squirrels/package_data/base_project/models/builds/build_example.sql +16 -0
  45. squirrels/package_data/base_project/models/builds/build_example.yml +55 -0
  46. squirrels/package_data/base_project/models/dbviews/dbview_example.sql +12 -22
  47. squirrels/package_data/base_project/models/dbviews/dbview_example.yml +26 -0
  48. squirrels/package_data/base_project/models/federates/federate_example.py +38 -15
  49. squirrels/package_data/base_project/models/federates/federate_example.sql +16 -2
  50. squirrels/package_data/base_project/models/federates/federate_example.yml +65 -0
  51. squirrels/package_data/base_project/models/sources.yml +39 -0
  52. squirrels/package_data/base_project/parameters.yml +36 -21
  53. squirrels/package_data/base_project/pyconfigs/connections.py +6 -11
  54. squirrels/package_data/base_project/pyconfigs/context.py +20 -33
  55. squirrels/package_data/base_project/pyconfigs/parameters.py +19 -21
  56. squirrels/package_data/base_project/pyconfigs/user.py +23 -0
  57. squirrels/package_data/base_project/seeds/seed_categories.yml +15 -0
  58. squirrels/package_data/base_project/seeds/seed_subcategories.csv +15 -15
  59. squirrels/package_data/base_project/seeds/seed_subcategories.yml +21 -0
  60. squirrels/package_data/base_project/squirrels.yml.j2 +17 -40
  61. squirrels/parameters.py +20 -20
  62. {squirrels-0.4.0.dist-info → squirrels-0.5.0b1.dist-info}/METADATA +31 -32
  63. squirrels-0.5.0b1.dist-info/RECORD +70 -0
  64. {squirrels-0.4.0.dist-info → squirrels-0.5.0b1.dist-info}/WHEEL +1 -1
  65. squirrels-0.5.0b1.dist-info/entry_points.txt +3 -0
  66. {squirrels-0.4.0.dist-info → squirrels-0.5.0b1.dist-info/licenses}/LICENSE +1 -1
  67. squirrels/_authenticator.py +0 -85
  68. squirrels/_environcfg.py +0 -84
  69. squirrels/package_data/assets/favicon.ico +0 -0
  70. squirrels/package_data/assets/index.css +0 -1
  71. squirrels/package_data/assets/index.js +0 -58
  72. squirrels/package_data/base_project/dashboards.yml +0 -10
  73. squirrels/package_data/base_project/env.yml +0 -29
  74. squirrels/package_data/base_project/models/dbviews/dbview_example.py +0 -47
  75. squirrels/package_data/base_project/pyconfigs/auth.py +0 -45
  76. squirrels/package_data/templates/index.html +0 -18
  77. squirrels/project.py +0 -378
  78. squirrels/user_base.py +0 -55
  79. squirrels-0.4.0.dist-info/RECORD +0 -60
  80. 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, HTTPException, Response, status
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
- import os, io, time, mimetypes, traceback, uuid, pandas as pd
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 ._version import sq_major_version
16
- from ._authenticator import User
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 .project import SquirrelsProject
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._authenticator
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": "Project Metadata",
61
- "description": "Get information on project such as name, version, and other API endpoints",
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": "Login",
65
- "description": "Submit username and password, and get token for authentication",
113
+ "name": "Project Metadata",
114
+ "description": "Get information on project such as name, version, and other API endpoints",
66
115
  },
67
116
  {
68
- "name": "Catalogs",
69
- "description": "Get catalog of datasets and dashboards with endpoints for their parameters and results",
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.manifest_cfg.dashboards:
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 u.InvalidInputError as exc:
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=status.HTTP_400_BAD_REQUEST, content={"message": str(exc), "blame": "API client"}
186
+ status_code=status_code, content={"message": message, "blame": "API client", "error_code": exc.error_code}
121
187
  )
122
- except u.FileExecutionError as exc:
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 u.ConfigurationError as exc:
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 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}')
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.add((u.normalize_name(key), val))
195
- selections = frozenset(selections)
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 get_query_models_from_widget_params(parameters: list):
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 parameters
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
- QueryModelForPost = create_model("RequestBodyParams", **{
215
- param: param_fields[param].as_body_info() for param in parameters
216
- }) # type: ignore
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
- # Login & Authorization
232
- token_path = base_path + '/token'
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=token_path, auto_error=False)
321
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl=login_path, auto_error=False)
235
322
 
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:
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
- # Datasets / Dashboards Catalog API
253
- data_catalog_path = base_path + '/data-catalog'
254
- dataset_results_path = base_path + '/dataset/{dataset}'
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
- dashboard_results_path = base_path + '/dashboard/{dashboard}'
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: User | None) -> arm.CatalogModel:
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=name, label=config.label, description=config.description,
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, config in self.manifest_cfg.dashboards.items():
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 u.ConfigurationError(f"No dashboard file found for: {name}")
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, description=config.description, result_format=dashboard_format,
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
- return arm.CatalogModel(datasets=dataset_items, dashboards=dashboard_items)
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=["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
- })
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, ...], user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
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
- if len(selections) > 1:
304
- raise u.InvalidInputError(f"The /parameters endpoint takes at most 1 query parameter. Got {dict(selections)}")
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
- param_set = self.param_cfg_set.apply_selections(
307
- parameters_tuple, dict(selections), user, updates_only=True, request_version=request_version
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
- 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)
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, ...], user: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
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, request_version)
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], user: User | None, headers: Mapping, params: Mapping
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
- 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()
583
+ self._validate_request_params(all_request_params, params)
332
584
 
333
- def validate_parameters_list(parameters: list[str], entity_type: str) -> None:
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 u.ConfigurationError(f"{entity_type} '{dataset_name}' use parameter '{param}' which doesn't exist. Available parameters are:"
338
- f"\n {all_params}")
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: 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
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
- 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))
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: 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)
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: User | None, headers: Mapping, params: Mapping
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
- 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
- })
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: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
665
+ dashboard: str, user: BaseUser | None, selections: tuple[tuple[str, Any], ...]
372
666
  ) -> 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)
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: User | None, selections: frozenset[tuple[str, Any]], request_version: int | None
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, request_version)
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: User | None, headers: Mapping, params: Mapping
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, request_version = get_selections_and_request_version(params, headers)
393
- dashboard_obj = await get_dashboard_function(dashboard_name, user, selections, request_version)
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
- QueryModelForGet, QueryModelForPost = get_query_models_from_widget_params(dataset_config.parameters)
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: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
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
- result = await get_parameters_definition(parameters_list, user, request.headers, asdict(params))
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: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
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
- result = await get_parameters_definition(parameters_list, user, request.headers, params.model_dump())
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: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
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.headers, asdict(params))
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: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
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
- result = await get_dataset_results_definition(curr_dataset_name, user, request.headers, params.model_dump())
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, dashboard_config in self.manifest_cfg.dashboards.items():
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(dashboard_config.parameters, "Dashboard")
764
+ validate_parameters_list(dashboard.config.parameters, "Dashboard")
470
765
 
471
- QueryModelForGet, QueryModelForPost = get_query_models_from_widget_params(dashboard_config.parameters)
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: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
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.manifest_cfg.dashboards[curr_dashboard_name].parameters
480
- result = await get_parameters_definition(parameters_list, user, request.headers, asdict(params))
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: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
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.manifest_cfg.dashboards[curr_dashboard_name].parameters
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
- result = await get_parameters_definition(parameters_list, user, request.headers, params.model_dump())
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=dashboard_config.description, response_class=Response)
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: QueryModelForGet, user: User | None = Depends(get_current_user) # type: ignore
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.headers, asdict(params))
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=dashboard_config.description, response_class=Response)
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: QueryModelForPost, user: User | None = Depends(get_current_user) # type: ignore
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
- result = await get_dashboard_results_definition(curr_dashboard_name, user, request.headers, params.model_dump())
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 Metadata API
518
- def get_project_metadata0() -> arm.ProjectModel:
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
- )
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
- @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
- })
535
-
536
- # Squirrels Testing UI
537
- static_dir = u.Path(os.path.dirname(__file__), c.PACKAGE_DATA_FOLDER, c.ASSETS_FOLDER)
538
- app.mount('/'+c.ASSETS_FOLDER, StaticFiles(directory=static_dir), name=c.ASSETS_FOLDER)
539
-
540
- templates_dir = u.Path(os.path.dirname(__file__), c.PACKAGE_DATA_FOLDER, c.TEMPLATES_FOLDER)
541
- templates = Jinja2Templates(directory=templates_dir)
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
- @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
- })
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 server
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)