Skip to content

Commit

Permalink
Add Recap gateway
Browse files Browse the repository at this point in the history
Recap now ships with a little HTTP/JSON gateway. The gateway has two paths:

- ls
- schema

These paths list subpaths and fetch schemas. For example:

```
GET http://127.0.0.1:8000/ls
["bq", "pg"]

GET http://127.0.0.1:8000/ls/pg
["postgres","template0","template1","testdb"]

GET http://127.0.0.1:8000/schema/pg/testdb/public/test_types
{"type": "struct", "fields": ... }
```

The gateway is configured using environment variableas and supports a `.env`
file (via `pydantic-settings`).

```bash
RECAP_SYSTEMS__BQ=bigquery://
RECAP_SYSTEMS__PG=postgresql://localhost:5432/testdb
```

In the future, Recap's CLI will use the same environment variables.

I'm leaving the gateway integration tests for a follow-on PR.
  • Loading branch information
criccomini committed Aug 29, 2023
1 parent 5dc71df commit 1a28991
Show file tree
Hide file tree
Showing 9 changed files with 235 additions and 9 deletions.
170 changes: 165 additions & 5 deletions pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ json = [
"referencing>=0.30.0",
"httpx>=0.24.1",
]
gateway = [
"fastapi>=0.103.0",
"pydantic>=2.3.0",
"pydantic-settings>=2.0.3",
"uvicorn>=0.23.2",
]

[build-system]
requires = ["pdm-backend"]
Expand Down Expand Up @@ -107,6 +113,7 @@ unit = "pytest tests/unit -vv"
spec = "pytest tests/spec -vv"
integration = "pytest tests/integration -vv"
test = {composite = ["unit", "spec"]}
serve ="uvicorn recap.gateway.app:app --reload"

[tool.pytest.ini_options]
addopts = [
Expand Down
2 changes: 1 addition & 1 deletion recap/clients/dbapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def ls_tables(self, catalog: str, schema: str) -> list[str]:
)
return [row[0] for row in cursor.fetchall()]

def get_schema(self, table: str, schema: str, catalog: str) -> StructType:
def get_schema(self, catalog: str, schema: str, table: str) -> StructType:
cursor = self.connection.cursor()
cursor.execute(
f"""
Expand Down
Empty file added recap/gateway/__init__.py
Empty file.
48 changes: 48 additions & 0 deletions recap/gateway/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from fastapi import Depends, FastAPI

from recap.clients import Client, create_client
from recap.gateway.settings import RecapSettings
from recap.types import to_dict

app = FastAPI()
settings = RecapSettings()


async def get_reader(system_name: str | None = None):
if system_name and (url := settings.systems.get(system_name)):
with create_client(url.unicode_string()) as client:
yield client
else:
yield None


@app.get("/ls")
@app.get("/ls/{system_name}")
@app.get("/ls/{system_name}/{path:path}")
async def ls(
system_name: str | None = None,
path: str | None = None,
client: Client | None = Depends(get_reader),
) -> list[str]:
if system_name is None:
return list(settings.systems.keys())
if client is None:
raise ValueError(f"Unknown system: {system_name}")
return client.ls(*_args(path))


@app.get("/schema/{system_name}/{path:path}")
async def schema(
path: str,
client: Client = Depends(get_reader),
) -> dict:
print(path)
recap_struct = client.get_schema(*_args(path))
recap_dict = to_dict(recap_struct)
if not isinstance(recap_dict, dict):
raise ValueError(f"Expected dict, got {type(recap_dict)}")
return recap_dict


def _args(path: str | None) -> list[str]:
return path.strip("/").split("/") if path else []
11 changes: 11 additions & 0 deletions recap/gateway/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from pydantic import AnyUrl, Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class RecapSettings(BaseSettings):
systems: dict[str, AnyUrl] = Field(default_factory=dict)
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="recap_",
env_nested_delimiter="__",
)
2 changes: 1 addition & 1 deletion tests/integration/clients/test_mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def test_struct_method(self):
client = MysqlClient(self.connection) # type: ignore

# Test 'test_types' table. MySQL catalog is always 'def'.
test_types_struct = client.get_schema("test_types", "testdb", "def")
test_types_struct = client.get_schema("def", "testdb", "test_types")

# Define the expected output for 'test_types' table
expected_fields = [
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/clients/test_postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def test_struct_method(self):
client = PostgresqlClient(self.connection) # type: ignore

# Test 'test_types' table
test_types_struct = client.get_schema("test_types", "public", "testdb")
test_types_struct = client.get_schema("testdb", "public", "test_types")

# Define the expected output for 'test_types' table
expected_fields = [
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/clients/test_snowflake.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def setup_class(cls):

def test_struct_method(self):
client = SnowflakeClient(self.connection) # type: ignore
test_types_struct = client.get_schema("TEST_TYPES", "PUBLIC", "TESTDB")
test_types_struct = client.get_schema("TESTDB", "PUBLIC", "TEST_TYPES")
expected_fields = [
UnionType(
default=None,
Expand Down

0 comments on commit 1a28991

Please sign in to comment.