Skip to content

Commit

Permalink
Add basic filter query support (closes #6)
Browse files Browse the repository at this point in the history
  • Loading branch information
hjoukl committed Jan 17, 2023
1 parent 1f52704 commit 1f408d2
Show file tree
Hide file tree
Showing 5 changed files with 230 additions and 51 deletions.
74 changes: 44 additions & 30 deletions src/datarest/_cfgfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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]
Expand All @@ -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
Expand All @@ -101,42 +103,54 @@ 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


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)

Expand Down
153 changes: 153 additions & 0 deletions src/datarest/_crudrouter_ext.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 12 additions & 11 deletions src/datarest/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"""

import collections
from typing import List
from typing import List

from . import _cfgfile
from . import _sqlmodel_ext
Expand All @@ -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)
Expand All @@ -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
Loading

0 comments on commit 1f408d2

Please sign in to comment.