diff --git a/src/datarest/_cfgfile.py b/src/datarest/_cfgfile.py index 18dcab3..01627c8 100644 --- a/src/datarest/_cfgfile.py +++ b/src/datarest/_cfgfile.py @@ -33,7 +33,7 @@ class SchemaSpecEnum(str, enum.Enum): """ sqlalchemy = "https://www.sqlalchemy.org/" data_resource = "https://specs.frictionlessdata.io/data-resource/" - + def __str__(self): # Avoid IdEnum.xxx output of Enum class, provide actual string value return self.value @@ -45,6 +45,7 @@ class SQLAlchemyTable(BaseModel): paginate: int = 10 expose_routes: Optional[List[ExposeRoutesEnum]] = [ ExposeRoutesEnum.get_one] + query_params: Optional[List[str]] = [] class TableschemaTable(BaseModel): @@ -55,13 +56,14 @@ class TableschemaTable(BaseModel): paginate: int = 10 expose_routes: Optional[List[ExposeRoutesEnum]] = [ ExposeRoutesEnum.get_one] + query_params: Optional[List[str]] = [] -Table = Annotated[ +Table = Annotated[ Union[TableschemaTable, SQLAlchemyTable], Field(discriminator='schema_spec') ] - + class Datatables(BaseModel): __root__: Dict[str, Table] @@ -71,10 +73,10 @@ def __getattr__(self, attr): # if you want to use '.' return self.__root__[attr] except KeyError as exc: raise AttributeError from exc - + def items(self): return self.__root__.items() - + class App(BaseModel): title: str @@ -101,33 +103,43 @@ class AppConfig(BaseModel): def app_config( - table, title=None, description='', version='0.1.0', + table, + title=None, + description='', + version='0.1.0', connect_string="sqlite:///app.db", - expose_routes=(ExposeRoutesEnum.get_one,)): + expose_routes=(ExposeRoutesEnum.get_one, ), + query_params=() + ): """Return AppConfig object. """ title = table if title is None else title - #expose_routes = list(expose_routes) - config = AppConfig(datarest=Datarest( - fastapi=Fastapi( - app=App( - title=f"{title} API", - description=description, - version=version, - ) - ), - database=Database( - connect_string=connect_string, - ), - datatables=Datatables(__root__={ - table: TableschemaTable( - schema_spec="https://specs.frictionlessdata.io/data-resource/", - schema=f"{table}.yaml", - dbtable=table, - expose_routes=expose_routes, - ) - }), - )) + # expose_routes = list(expose_routes) + config = AppConfig( + datarest=Datarest( + fastapi=Fastapi( + app=App( + title=f"{title} API", + description=description, + version=version, + ) + ), + database=Database(connect_string=connect_string, ), + datatables=Datatables( + __root__={ + table: + TableschemaTable( + schema_spec= + "https://specs.frictionlessdata.io/data-resource/", + schema=f"{table}.yaml", + dbtable=table, + expose_routes=expose_routes, + query_params=query_params, + ) + } + ), + ) + ) return config @@ -135,8 +147,10 @@ def write_app_config(cfg_path, app_config): """Write app.yaml config YAML string to cfg_path file. """ app_config_yaml = yaml.safe_dump( - app_config.dict(by_alias=True), default_flow_style=False, - sort_keys=False) + app_config.dict(by_alias=True), + default_flow_style=False, + sort_keys=False + ) with open(cfg_path, encoding='utf-8', mode='w') as cfg_file: cfg_file.write(app_config_yaml) diff --git a/src/datarest/_crudrouter_ext.py b/src/datarest/_crudrouter_ext.py new file mode 100644 index 0000000..776093c --- /dev/null +++ b/src/datarest/_crudrouter_ext.py @@ -0,0 +1,153 @@ +import textwrap +from typing import Any, Dict, List, Optional, Type, TypeVar, Union + +from fastapi import Depends, Response, Query, status +from fastapi_crudrouter import SQLAlchemyCRUDRouter +from fastapi_crudrouter.core.sqlalchemy import ( + DEPENDENCIES, CALLABLE_LIST, PAGINATION, SCHEMA, Model, Session + ) +import pydantic + +from . import _database + + +# TODO: Refactor to separate module +T = TypeVar("T", bound=pydantic.BaseModel) +FILTER = Dict[str, Optional[Union[int, float, str, bool]]] + + +filter_mapping = { + "int": int, + "float": float, + "bool": bool, + "str": str, + "ConstrainedStrValue": str, +} + +# Customize some CRUDRouter status code defaults since they're suboptimal +custom_routes_status = { + 'create': status.HTTP_201_CREATED, + } + + +def status_code(http_code: int = status.HTTP_200_OK): + + def resp_status_code(response: Response): + response.status_code = http_code + return response + + return resp_status_code + + +# TODO: Any chance to do this without exec? AST? +# - Take a look at dataclass and/or attrs +# - see also https://github.com/tiangolo/fastapi/issues/4700 for potential +# problems + hints +# Gratefully adapted from https://github.com/awtkns/fastapi-crudrouter/pull/61 +def query_factory( + schema: Type[T], + query_params: Optional[List[str]] = None + ) -> Any: + """Dynamically build a Fastapi dependency for query parameters. + + Based on available fields in the model and the given query_params names + to expose. + """ + query_params = [] if query_params is None else query_params + + arg_template = "{name}: Optional[{typ}] = Query(None)" + args_list = [] + + ret_dict_arg_template = "{}={}" + ret_dict_args = [] + # TODO: Exclude REST resource id field from allowed query params + for name, field in schema.__fields__.items(): + if (name in query_params + and field.type_.__name__ in filter_mapping): + args_list.append( + arg_template.format( + name=name, + typ=filter_mapping[field.type_.__name__].__name__) + ) + ret_dict_args.append( + ret_dict_arg_template.format(name, field.name) + ) + if args_list: + args_str = ", ".join(args_list) + ret_dict_args_str = ", ".join(ret_dict_args) + + filter_func_src = textwrap.dedent(f""" + def filter_func({args_str}) -> FILTER: + ret = dict({ret_dict_args_str}) + return {{k:v for k, v in ret.items() if v is not None}} + """).strip("\n") + exec(filter_func_src, globals(), locals()) + _filter_func = locals().get("filter_func") + return _filter_func + else: + return None + + +class FilteringSQLAlchemyCRUDRouter(SQLAlchemyCRUDRouter): + + def __init__( + self, + schema: Type[SCHEMA], + db_model: Model, + db: "Session", + create_schema: Optional[Type[SCHEMA]] = None, + update_schema: Optional[Type[SCHEMA]] = None, + prefix: Optional[str] = None, + tags: Optional[List[str]] = None, + paginate: Optional[int] = None, + get_all_route: Union[bool, DEPENDENCIES] = True, + get_one_route: Union[bool, DEPENDENCIES] = True, + create_route: Union[bool, DEPENDENCIES] = True, + update_route: Union[bool, DEPENDENCIES] = True, + delete_one_route: Union[bool, DEPENDENCIES] = True, + delete_all_route: Union[bool, DEPENDENCIES] = True, + query_params: Optional[List[str]] = None, + **kwargs: Any + ) -> None: + query_params = [] if query_params is None else query_params + + self.filter = Depends(query_factory(schema, query_params)) + super().__init__( + schema=schema, + db_model=db_model, + db=db, + create_schema=create_schema, + update_schema=update_schema, + prefix=prefix, + tags=tags, + paginate=paginate, + get_all_route=get_all_route, + get_one_route=get_one_route, + create_route=create_route, + update_route=update_route, + delete_one_route=delete_one_route, + delete_all_route=delete_all_route, + **kwargs + ) + + def _get_all(self, *args: Any, **kwargs: Any) -> CALLABLE_LIST: + print(self, args, kwargs) + + def route( + db: Session = Depends(self.db_func), + pagination: PAGINATION = self.pagination, + filter_: FILTER = self.filter + ) -> List[Model]: + skip, limit = pagination.get("skip"), pagination.get("limit") + + db_models: List[Model] = ( + db.query(self.db_model) + .filter_by(**filter_) + .order_by(getattr(self.db_model, self._pk)) + .limit(limit) + .offset(skip) + .all() + ) + return db_models + + return route diff --git a/src/datarest/_models.py b/src/datarest/_models.py index 263de2c..e21d59d 100644 --- a/src/datarest/_models.py +++ b/src/datarest/_models.py @@ -2,7 +2,7 @@ """ import collections -from typing import List +from typing import List from . import _cfgfile from . import _sqlmodel_ext @@ -15,25 +15,25 @@ ModelCombo = collections.namedtuple( 'ModelCombo', ['resource_name', 'resource_model', 'resource_collection_model', 'dbtable', - 'id_columns', 'expose_routes', 'paginate']) + 'id_columns', 'expose_routes', 'query_params', 'paginate']) def create_model(model_name, model_def): """Dynamically create pydantic model from config model definition. - + FastAPI uses pydantic models to describe endpoint input/output data. - + Parameters: model_name: resource name string model_def: model definition from config file - + Returns: A ModelCombo object """ - # Create resource + collection model class names using standard Python + # Create resource + collection model class names using standard Python # naming conventions model_cls_name = model_name.title() collection_model_cls_name = f'{model_cls_name}CollectionModel' - + if model_def.schema_spec == _cfgfile.SchemaSpecEnum.data_resource: # TODO: Unify model creation code (parts are in _data_resource_models, # others in _sqlmodel_ext) @@ -48,20 +48,21 @@ def create_model(model_name, model_def): dbtable=model_def.dbtable, id_columns=id_columns, expose_routes=model_def.expose_routes, + query_params=model_def.query_params, paginate=model_def.paginate) - + raise ValueError('Unsupported data schema specification') def create_models(datatables: Datatables): """Loop over config data resources to create pydantic models. - + Parameters: datatables: Datatables model - + Returns: (model_name, model)-dictionary """ - models = {} + models = {} for model_name, model_def in datatables.items(): models[model_name] = create_model(model_name, model_def) return models diff --git a/src/datarest/_routes.py b/src/datarest/_routes.py index 8bb75ba..710373a 100644 --- a/src/datarest/_routes.py +++ b/src/datarest/_routes.py @@ -1,9 +1,11 @@ # Use fastapi_crudrouter to generate router endpoints from fastapi import Depends, Response, status -from fastapi_crudrouter import SQLAlchemyCRUDRouter as CRUDRouter + +from . import _crudrouter_ext from . import _database + # Disallow all routes per default to allow for selectively enabling routes expose_routes_default = { 'get_all_route': False, @@ -15,6 +17,14 @@ } +# TODO: Better move the custom status setting to crudrouter subclass, since we +# have one, anyway, for query support. +# Customize some CRUDRouter status code defaults since they're suboptimal +custom_routes_status = { + 'create': status.HTTP_201_CREATED, + } + + def status_code(http_code: int = status.HTTP_200_OK): def resp_status_code(response: Response): @@ -24,12 +34,6 @@ def resp_status_code(response: Response): return resp_status_code -# Customize some CRUDRouter status code defaults since they're suboptimal -custom_routes_status = { - 'create': status.HTTP_201_CREATED, - } - - # TODO: # - Use PATCH instead of PUT for update(?) def create_routes(app, models): @@ -41,16 +45,18 @@ def create_routes(app, models): for expose in model.expose_routes: route_arg = True custom_status = custom_routes_status.get(expose) + # Use a custom http response status by means of dependency if custom_status: route_arg = [Depends(status_code(custom_status))] expose_routes[f"{expose}_route"] = route_arg - router = CRUDRouter( + router = _crudrouter_ext.FilteringSQLAlchemyCRUDRouter( schema=model.resource_model, db_model=model.resource_model, db=_database.get_db, prefix=model.resource_name, paginate=model.paginate, + query_params=model.query_params, **expose_routes ) # TODO: diff --git a/src/datarest/cli.py b/src/datarest/cli.py index 5e7dfe9..a5a3b61 100644 --- a/src/datarest/cli.py +++ b/src/datarest/cli.py @@ -56,6 +56,9 @@ def datafile( 'uuid4_base64', help='Type of resource ID to expose'), primary_key: List[str] = typer.Option( [], help='Provide one or more primary key field(s)'), + query: List[str] = typer.Option( + [], help='Provide one or more field(s) eligible as query' + ' parameter'), description: Optional[List[str]] = typer.Option( None, help='Provide one or more field description(s)'), rewrite_datafile: bool = typer.Option( @@ -82,8 +85,10 @@ def datafile( title=table.title(), version='0.1.0', connect_string=connect_string, - expose_routes=expose) - ) + expose_routes=expose, + query_params=query, + ) + ) datafile_resource = frictionless.describe( datafile, encoding=encoding)