diff --git a/app/dataapi/domain/actions.py b/app/dataapi/domain/actions.py index 3d8a6c24..afb94e39 100644 --- a/app/dataapi/domain/actions.py +++ b/app/dataapi/domain/actions.py @@ -15,22 +15,7 @@ catalogs.RawCatalog.NATURE, ] -METADATA_ALLOWED_SCHEMAS = frozenset( - { - "common", - "rawdata", - "designation", - "icrs", - "cz", - "layer2", - "layer0", - "nature", - "note", - "photometry", - "distance", - "morphology", - }, -) +METADATA_ALLOWED_SCHEMAS = frozenset({"layer2"}) def _json_cell(value: object) -> object: diff --git a/app/dataapi/presentation/server.py b/app/dataapi/presentation/server.py index 604d129f..091236ad 100644 --- a/app/dataapi/presentation/server.py +++ b/app/dataapi/presentation/server.py @@ -81,14 +81,14 @@ def __init__( "/v1/tap/tables", http.HTTPMethod.GET, api.tap_tables, - "List TAP table metadata for whitelisted schemas.", + "List TAP table metadata for LEDA tables.", ), server.Route( "/v1/tap/sync", http.HTTPMethod.GET, api.tap_sync, - "Execute an arbitrary SQL query (TAP /sync).", - "Runs a read-only SQL query against whitelisted schemas and returns a VOTable-like JSON payload.", + "Execute a read-only SQL query against layer2 (TAP /sync).", + "Runs a read-only SQL query with search_path set to layer2 and returns a VOTable-like JSON payload.", rate_limit="60/minute", ), server.Route( diff --git a/app/dataapi/repository/repository.py b/app/dataapi/repository/repository.py index e0e4f5af..7a0f0012 100644 --- a/app/dataapi/repository/repository.py +++ b/app/dataapi/repository/repository.py @@ -176,6 +176,7 @@ def query_with_metadata( wrapped, timeout_seconds=timeout_seconds, read_only=True, + search_path="layer2", ) if not dict_rows: return repo_model.QueryWithMetadataResult(columns=[], rows=[]) diff --git a/app/lib/storage/postgres/postgres_storage.py b/app/lib/storage/postgres/postgres_storage.py index 105e4c08..91ed505a 100644 --- a/app/lib/storage/postgres/postgres_storage.py +++ b/app/lib/storage/postgres/postgres_storage.py @@ -146,6 +146,7 @@ def query( params: list[Any] | None = None, timeout_seconds: float | None = None, read_only: bool = False, + search_path: str | None = None, ) -> list[rows.DictRow]: log.debug("SQL query", query=self.query_str(query).replace("\n", " "), args=params or []) @@ -173,6 +174,10 @@ def _execute(cursor: psycopg.Cursor) -> list[rows.DictRow]: cursor.execute( sql.SQL("SET LOCAL statement_timeout = {}").format(sql.Literal(f"{timeout_ms}ms")) ) + if search_path is not None: + cursor.execute( + sql.SQL("SET LOCAL search_path TO {}").format(sql.Identifier(search_path)) + ) result = _execute(cursor) finally: if read_only: diff --git a/tests/dataapi/integration/metadata_api_test.py b/tests/dataapi/integration/metadata_api_test.py index b52ec335..8f6a7865 100644 --- a/tests/dataapi/integration/metadata_api_test.py +++ b/tests/dataapi/integration/metadata_api_test.py @@ -54,14 +54,14 @@ def test_tap_tables_default_max(self) -> None: data = response.json()["data"] self.assertIn("schemas", data) self.assertGreater(len(data["schemas"]), 0) - common = next(s for s in data["schemas"] if s["schema_name"] == "common") - bib = next(t for t in common["tables"] if t["name"] == 'common."bib"') - self.assertEqual(bib["type"], "table") - self.assertIn("columns", bib) - self.assertIsInstance(bib["columns"], list) - self.assertGreater(len(bib["columns"]), 0) - id_col = next(c for c in bib["columns"] if c["name"] == "id") - self.assertEqual(id_col["datatype"], "int") + layer2 = next(s for s in data["schemas"] if s["schema_name"] == "layer2") + icrs = next(t for t in layer2["tables"] if t["name"] == 'layer2."icrs"') + self.assertEqual(icrs["type"], "table") + self.assertIn("columns", icrs) + self.assertIsInstance(icrs["columns"], list) + self.assertGreater(len(icrs["columns"]), 0) + pgc_col = next(c for c in icrs["columns"] if c["name"] == "pgc") + self.assertEqual(pgc_col["datatype"], "int") def test_tap_tables_min(self) -> None: response = self.client.get("/api/v1/tap/tables", params={"detail": "min"}) @@ -84,24 +84,24 @@ def test_tap_sync_basic(self) -> None: response = self.client.get( "/api/v1/tap/sync", params={ - "query": "SELECT type_name, objclass, description FROM nature.object_type ORDER BY type_name LIMIT 1", + "query": "SELECT catalog FROM last_update ORDER BY catalog LIMIT 1", }, ) self.assertEqual(response.status_code, 200) table = response.json()["data"]["resource"]["table"] col_names = [c["name"] for c in table["columns"]] - self.assertEqual(col_names, ["type_name", "objclass", "description"]) - type_name_col = table["columns"][0] - self.assertEqual(type_name_col["datatype"], "char") - self.assertEqual(type_name_col["arraysize"], "*") + self.assertEqual(col_names, ["catalog"]) + catalog_col = table["columns"][0] + self.assertEqual(catalog_col["datatype"], "char") + self.assertEqual(catalog_col["arraysize"], "*") self.assertEqual(len(table["data"]), 1) - self.assertEqual(len(table["data"][0]), 3) + self.assertEqual(len(table["data"][0]), 1) def test_tap_sync_maxrec(self) -> None: response = self.client.get( "/api/v1/tap/sync", params={ - "query": "SELECT type_name FROM nature.object_type ORDER BY type_name", + "query": "SELECT catalog FROM last_update ORDER BY catalog", "maxrec": 2, }, ) @@ -113,7 +113,7 @@ def test_tap_sync_maxrec_not_bypassed_by_line_comment(self) -> None: response = self.client.get( "/api/v1/tap/sync", params={ - "query": "SELECT type_name FROM nature.object_type ORDER BY type_name --", + "query": "SELECT catalog FROM last_update ORDER BY catalog --", "maxrec": 2, }, ) @@ -125,7 +125,7 @@ def test_tap_sync_maxrec_not_bypassed_by_unterminated_block_comment(self) -> Non response = self.client.get( "/api/v1/tap/sync", params={ - "query": ("SELECT type_name FROM nature.object_type ORDER BY type_name) AS _tap_sync LIMIT 10000 /*"), + "query": ("SELECT catalog FROM last_update ORDER BY catalog) AS _tap_sync LIMIT 10000 /*"), "maxrec": 2, }, ) @@ -135,9 +135,7 @@ def test_tap_sync_rejects_semicolon_separated_queries(self) -> None: response = self.client.get( "/api/v1/tap/sync", params={ - "query": ( - "SELECT type_name FROM nature.object_type LIMIT 1; SELECT type_name FROM nature.object_type LIMIT 1" - ), + "query": ("SELECT catalog FROM last_update LIMIT 1; SELECT catalog FROM last_update LIMIT 1"), }, ) self.assertEqual(response.status_code, 500) @@ -146,13 +144,13 @@ def test_tap_sync_like_with_percent_wildcard(self) -> None: response = self.client.get( "/api/v1/tap/sync", params={ - "query": "SELECT type_name FROM nature.object_type WHERE type_name NOT LIKE '%gal%'", + "query": "SELECT catalog FROM last_update WHERE catalog NOT LIKE '%icrs%'", }, ) self.assertEqual(response.status_code, 200) table = response.json()["data"]["resource"]["table"] - self.assertEqual([c["name"] for c in table["columns"]], ["type_name"]) - self.assertTrue(all("gal" not in row[0].lower() for row in table["data"])) + self.assertEqual([c["name"] for c in table["columns"]], ["catalog"]) + self.assertTrue(all("icrs" not in row[0] for row in table["data"])) def test_tap_sync_query_timeout(self) -> None: with self.assertRaises(psycopg.errors.QueryCanceled): diff --git a/tests/regression/tap_compliance.py b/tests/regression/tap_compliance.py index 6a542028..4ba71852 100644 --- a/tests/regression/tap_compliance.py +++ b/tests/regression/tap_compliance.py @@ -30,7 +30,7 @@ def check_tap_sync(session: requests.Session) -> None: response = session.get( "/v1/tap/sync", params={ - "query": "SELECT type_name, objclass, description FROM nature.object_type ORDER BY type_name", + "query": "SELECT pgc, ra, dec FROM icrs ORDER BY pgc", "lang": "PostgreSQL", "format": "json", "maxrec": 5, @@ -38,8 +38,10 @@ def check_tap_sync(session: requests.Session) -> None: ) response.raise_for_status() table = response.json()["data"]["resource"]["table"] - assert [c["name"] for c in table["columns"]] == ["type_name", "objclass", "description"] - assert all(c["datatype"] == "char" for c in table["columns"]) + assert [c["name"] for c in table["columns"]] == ["pgc", "ra", "dec"] + assert table["columns"][0]["datatype"] == "int" + assert table["columns"][1]["datatype"] == "double" + assert table["columns"][2]["datatype"] == "double" assert len(table["data"]) == 5 assert all(len(row) == 3 for row in table["data"])