From ad54533155141323be245108949bf4c9d519f5fa Mon Sep 17 00:00:00 2001 From: sinisaos Date: Thu, 9 Dec 2021 23:21:47 +0100 Subject: [PATCH] added a fields attribute to schema (#114) * added a fields attribute to schema * fixing wrong code * make it work with max_joins Co-authored-by: Daniel Townsend --- piccolo_api/crud/endpoints.py | 52 ++++++++++++++- tests/crud/test_crud_endpoints.py | 89 +++++++++++++++++++++++-- tests/fastapi/test_fastapi_endpoints.py | 67 ++++++++++++++++++- 3 files changed, 197 insertions(+), 11 deletions(-) diff --git a/piccolo_api/crud/endpoints.py b/piccolo_api/crud/endpoints.py index a7640138..d7692b76 100644 --- a/piccolo_api/crud/endpoints.py +++ b/piccolo_api/crud/endpoints.py @@ -78,6 +78,45 @@ class Params: visible_fields: str = field(default="") +def get_visible_fields_options( + table: t.Type[Table], + exclude_secrets: bool = False, + max_joins: int = 0, + prefix: str = "", +) -> t.Tuple[str, ...]: + """ + In the schema, we tell the user which fields are allowed with the + ``__visible_fields`` GET parameter. This function extracts the column + names, and names of related columns too. + + :param prefix: + Used internally by this function - the user doesn't need to set this. + + """ + fields = [] + + for column in table._meta.columns: + if exclude_secrets and column._meta.secret: + continue + + column_name = ( + f"{prefix}.{column._meta.name}" if prefix else column._meta.name + ) + fields.append(column_name) + + if isinstance(column, ForeignKey) and max_joins > 0: + fields.extend( + get_visible_fields_options( + table=column._foreign_key_meta.resolved_references, + exclude_secrets=exclude_secrets, + max_joins=max_joins - 1, + prefix=column_name, + ) + ) + + return tuple(fields) + + class PiccoloCRUD(Router): """ Wraps a Piccolo table with CRUD methods for use in a REST API. @@ -93,7 +132,7 @@ def __init__( page_size: int = 15, exclude_secrets: bool = True, validators: Validators = Validators(), - schema_extra: t.Dict[str, t.Any] = {}, + schema_extra: t.Optional[t.Dict[str, t.Any]] = None, max_joins: int = 0, ) -> None: """ @@ -145,6 +184,10 @@ def __init__( sensitive nature and the client is highly trusted. Consider using it with ``exclude_secrets=True``. + To see which fields can be filtered in this way, you can check + the ``visible_fields_options`` value returned by the ``/schema`` + endpoint. + """ # noqa: E501 self.table = table self.page_size = page_size @@ -152,9 +195,14 @@ def __init__( self.allow_bulk_delete = allow_bulk_delete self.exclude_secrets = exclude_secrets self.validators = validators - self.schema_extra = schema_extra self.max_joins = max_joins + schema_extra = schema_extra if isinstance(schema_extra, dict) else {} + schema_extra["visible_fields_options"] = get_visible_fields_options( + table=table, exclude_secrets=exclude_secrets, max_joins=max_joins + ) + self.schema_extra = schema_extra + root_methods = ["GET"] if not read_only: root_methods += ( diff --git a/tests/crud/test_crud_endpoints.py b/tests/crud/test_crud_endpoints.py index 914e156e..90ccff62 100644 --- a/tests/crud/test_crud_endpoints.py +++ b/tests/crud/test_crud_endpoints.py @@ -8,7 +8,11 @@ from starlette.datastructures import QueryParams from starlette.testclient import TestClient -from piccolo_api.crud.endpoints import GreaterThan, PiccoloCRUD +from piccolo_api.crud.endpoints import ( + GreaterThan, + PiccoloCRUD, + get_visible_fields_options, +) class Movie(Table): @@ -30,6 +34,19 @@ class TopSecret(Table): confidential = Secret() +class TestGetVisibleFieldsOptions(TestCase): + def test_without_joins(self): + response = get_visible_fields_options(table=Role, max_joins=0) + self.assertEqual(response, ("id", "movie", "name")) + + def test_with_joins(self): + response = get_visible_fields_options(table=Role, max_joins=1) + self.assertEqual( + response, + ("id", "movie", "movie.id", "movie.name", "movie.rating", "name"), + ) + + class TestParams(TestCase): def test_split_params(self): """ @@ -270,10 +287,12 @@ def test_get_references(self): class TestSchema(TestCase): def setUp(self): - Movie.create_table(if_not_exists=True).run_sync() + for table in (Movie, Role): + table.create_table(if_not_exists=True).run_sync() def tearDown(self): - Movie.alter().drop_table().run_sync() + for table in (Role, Movie): + table.alter().drop_table().run_sync() def test_get_schema(self): """ @@ -284,9 +303,8 @@ def test_get_schema(self): response = client.get("/schema/") self.assertTrue(response.status_code == 200) - response_json = response.json() self.assertEqual( - response_json, + response.json(), { "title": "MovieIn", "type": "object", @@ -307,6 +325,11 @@ def test_get_schema(self): }, "required": ["name"], "help_text": None, + "visible_fields_options": [ + "id", + "name", + "rating", + ], }, ) @@ -330,9 +353,8 @@ class Rating(Enum): response = client.get("/schema/") self.assertTrue(response.status_code == 200) - response_json = response.json() self.assertEqual( - response_json, + response.json(), { "title": "ReviewIn", "type": "object", @@ -356,6 +378,59 @@ class Rating(Enum): } }, "help_text": None, + "visible_fields_options": [ + "id", + "score", + ], + }, + ) + + def test_get_schema_with_joins(self): + """ + Make sure that if a Table has columns with joins specified, they + appear in the schema. + """ + client = TestClient( + PiccoloCRUD(table=Role, read_only=False, max_joins=1) + ) + + response = client.get("/schema/") + self.assertTrue(response.status_code == 200) + + self.assertEqual( + response.json(), + { + "title": "RoleIn", + "type": "object", + "properties": { + "movie": { + "title": "Movie", + "extra": { + "foreign_key": True, + "to": "movie", + "help_text": None, + "choices": None, + }, + "nullable": True, + "type": "integer", + }, + "name": { + "title": "Name", + "extra": {"help_text": None, "choices": None}, + "nullable": False, + "maxLength": 100, + "type": "string", + }, + }, + "help_text": None, + "visible_fields_options": [ + "id", + "movie", + "movie.id", + "movie.name", + "movie.rating", + "name", + ], }, ) diff --git a/tests/fastapi/test_fastapi_endpoints.py b/tests/fastapi/test_fastapi_endpoints.py index 5dcc4c7e..fe0794f6 100644 --- a/tests/fastapi/test_fastapi_endpoints.py +++ b/tests/fastapi/test_fastapi_endpoints.py @@ -2,7 +2,7 @@ from unittest import TestCase from fastapi import FastAPI -from piccolo.columns import Integer, Varchar +from piccolo.columns import ForeignKey, Integer, Varchar from piccolo.columns.readable import Readable from piccolo.table import Table from starlette.testclient import TestClient @@ -20,6 +20,11 @@ def get_readable(cls) -> Readable: return Readable(template="%s", columns=[cls.name]) +class Role(Table): + movie = ForeignKey(Movie) + name = Varchar(length=100) + + app = FastAPI() @@ -31,6 +36,14 @@ def get_readable(cls) -> Readable: ), ) +FastAPIWrapper( + root_url="/roles/", + fastapi_app=app, + piccolo_crud=PiccoloCRUD( + table=Role, read_only=False, allow_bulk_delete=True, max_joins=1 + ), +) + class TestOpenAPI(TestCase): def setUp(self): @@ -106,6 +119,53 @@ def test_schema(self): }, }, "help_text": None, + "visible_fields_options": [ + "id", + "name", + "rating", + ], + }, + ) + + def test_schema_joins(self): + client = TestClient(app) + response = client.get("/roles/schema/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "title": "RoleIn", + "type": "object", + "properties": { + "movie": { + "title": "Movie", + "extra": { + "foreign_key": True, + "to": "movie", + "help_text": None, + "choices": None, + }, + "nullable": True, + "type": "integer", + }, + "name": { + "title": "Name", + "extra": {"help_text": None, "choices": None}, + "nullable": False, + "maxLength": 100, + "type": "string", + }, + }, + "help_text": None, + "visible_fields_options": [ + "id", + "movie", + "movie.id", + "movie.name", + "movie.rating", + "name", + ], }, ) @@ -128,7 +188,10 @@ def test_references(self): client = TestClient(app) response = client.get("/movies/references/") self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"references": []}) + self.assertEqual( + response.json(), + {"references": [{"columnName": "movie", "tableName": "role"}]}, + ) def test_delete(self): client = TestClient(app)