shirube 0.1.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. shirube-0.1.0b1/.gitignore +75 -0
  2. shirube-0.1.0b1/PKG-INFO +119 -0
  3. shirube-0.1.0b1/README.md +103 -0
  4. shirube-0.1.0b1/pyproject.toml +68 -0
  5. shirube-0.1.0b1/src/shirube/__init__.py +3 -0
  6. shirube-0.1.0b1/src/shirube/__main__.py +64 -0
  7. shirube-0.1.0b1/src/shirube/adapters/README.md +5 -0
  8. shirube-0.1.0b1/src/shirube/adapters/__init__.py +1 -0
  9. shirube-0.1.0b1/src/shirube/adapters/api/__init__.py +1 -0
  10. shirube-0.1.0b1/src/shirube/adapters/api/app.py +162 -0
  11. shirube-0.1.0b1/src/shirube/adapters/api/dependencies.py +87 -0
  12. shirube-0.1.0b1/src/shirube/adapters/api/errors.py +45 -0
  13. shirube-0.1.0b1/src/shirube/adapters/api/routes/__init__.py +1 -0
  14. shirube-0.1.0b1/src/shirube/adapters/api/routes/connections.py +54 -0
  15. shirube-0.1.0b1/src/shirube/adapters/api/routes/data.py +114 -0
  16. shirube-0.1.0b1/src/shirube/adapters/api/routes/health.py +33 -0
  17. shirube-0.1.0b1/src/shirube/adapters/api/routes/profiles.py +142 -0
  18. shirube-0.1.0b1/src/shirube/adapters/api/routes/schema.py +107 -0
  19. shirube-0.1.0b1/src/shirube/adapters/keyring/__init__.py +1 -0
  20. shirube-0.1.0b1/src/shirube/adapters/keyring/secret_store.py +32 -0
  21. shirube-0.1.0b1/src/shirube/adapters/persistence/__init__.py +1 -0
  22. shirube-0.1.0b1/src/shirube/adapters/persistence/bootstrap.py +15 -0
  23. shirube-0.1.0b1/src/shirube/adapters/persistence/database.py +44 -0
  24. shirube-0.1.0b1/src/shirube/adapters/persistence/models.py +29 -0
  25. shirube-0.1.0b1/src/shirube/adapters/persistence/profile_repository.py +87 -0
  26. shirube-0.1.0b1/src/shirube/adapters/postgres/__init__.py +1 -0
  27. shirube-0.1.0b1/src/shirube/adapters/postgres/_common.py +108 -0
  28. shirube-0.1.0b1/src/shirube/adapters/postgres/connector.py +28 -0
  29. shirube-0.1.0b1/src/shirube/adapters/postgres/data_reader.py +198 -0
  30. shirube-0.1.0b1/src/shirube/adapters/postgres/schema_inspector.py +236 -0
  31. shirube-0.1.0b1/src/shirube/application/README.md +5 -0
  32. shirube-0.1.0b1/src/shirube/application/__init__.py +1 -0
  33. shirube-0.1.0b1/src/shirube/application/connections.py +49 -0
  34. shirube-0.1.0b1/src/shirube/application/data.py +55 -0
  35. shirube-0.1.0b1/src/shirube/application/profiles.py +113 -0
  36. shirube-0.1.0b1/src/shirube/application/schema.py +46 -0
  37. shirube-0.1.0b1/src/shirube/config.py +79 -0
  38. shirube-0.1.0b1/src/shirube/domain/README.md +5 -0
  39. shirube-0.1.0b1/src/shirube/domain/__init__.py +1 -0
  40. shirube-0.1.0b1/src/shirube/domain/connection.py +76 -0
  41. shirube-0.1.0b1/src/shirube/domain/data.py +98 -0
  42. shirube-0.1.0b1/src/shirube/domain/errors.py +65 -0
  43. shirube-0.1.0b1/src/shirube/domain/schema.py +106 -0
  44. shirube-0.1.0b1/src/shirube/logging_config.py +155 -0
  45. shirube-0.1.0b1/src/shirube/ports/README.md +4 -0
  46. shirube-0.1.0b1/src/shirube/ports/__init__.py +1 -0
  47. shirube-0.1.0b1/src/shirube/ports/repositories.py +123 -0
  48. shirube-0.1.0b1/src/shirube/py.typed +0 -0
  49. shirube-0.1.0b1/src/shirube/static/assets/index-D765xCTu.js +55 -0
  50. shirube-0.1.0b1/src/shirube/static/assets/index-DOQ9Bfpw.css +1 -0
  51. shirube-0.1.0b1/src/shirube/static/favicon.svg +13 -0
  52. shirube-0.1.0b1/src/shirube/static/index.html +14 -0
@@ -0,0 +1,75 @@
1
+ # ---- Python / FastAPI (backend) ----
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ parts/
14
+ sdist/
15
+ var/
16
+ wheels/
17
+ *.egg-info/
18
+ .installed.cfg
19
+ *.egg
20
+
21
+ # Virtual environments
22
+ .venv/
23
+ venv/
24
+ env/
25
+ ENV/
26
+
27
+ # Test / coverage
28
+ .pytest_cache/
29
+ .coverage
30
+ .coverage.*
31
+ htmlcov/
32
+ .tox/
33
+ .mypy_cache/
34
+ .ruff_cache/
35
+
36
+ # ---- Node / Vite (frontend) ----
37
+ node_modules/
38
+ .pnp
39
+ .pnp.js
40
+ # pnpm's content-addressable store, if it lands inside the repo.
41
+ .pnpm-store/
42
+ web/dist/
43
+ dist-ssr/
44
+ *.local
45
+
46
+ # Bundled SPA (generated into the API package at build time)
47
+ api/src/shirube/static/
48
+
49
+ # ---- Development database ----
50
+ # pagila SQL fetched by scripts/dev-db.sh; not vendored into the repo.
51
+ dev/pagila/
52
+
53
+ # Logs
54
+ logs/
55
+ *.log
56
+ npm-debug.log*
57
+ yarn-debug.log*
58
+ yarn-error.log*
59
+ pnpm-debug.log*
60
+
61
+ # ---- Secrets / local config ----
62
+ .env
63
+ .env.*
64
+ !.env.example
65
+
66
+ # ---- Editors / OS ----
67
+ .vscode/*
68
+ !.vscode/settings.json
69
+ !.vscode/extensions.json
70
+ .idea/
71
+ .claude/settings.local.json
72
+ *.swp
73
+ *.swo
74
+ .DS_Store
75
+ Thumbs.db
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: shirube
3
+ Version: 0.1.0b1
4
+ Summary: Understand your database as a map — built for the AI-coding era.
5
+ License-Expression: AGPL-3.0-or-later
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: fastapi>=0.115
8
+ Requires-Dist: keyring>=25.7.0
9
+ Requires-Dist: platformdirs>=4.2
10
+ Requires-Dist: psycopg[binary]>=3.3.4
11
+ Requires-Dist: pydantic-settings>=2.4
12
+ Requires-Dist: sqlalchemy>=2.0
13
+ Requires-Dist: structlog>=25.1.0
14
+ Requires-Dist: uvicorn[standard]>=0.51.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ <p align="center">
18
+ <img src="https://raw.githubusercontent.com/shou-taro/shirube/main/docs/images/logo.svg" width="88" alt="shirube" />
19
+ </p>
20
+
21
+ <h1 align="center">shirube</h1>
22
+
23
+ <p align="center">
24
+ <strong>AI writes the SQL.<br />You still own the schema.</strong><br />
25
+ <sub>標べ — a signpost for reading a database as a map.</sub>
26
+ </p>
27
+
28
+ <p align="center">
29
+ <a href="https://github.com/shou-taro/shirube/actions/workflows/ci.yml"><img src="https://github.com/shou-taro/shirube/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
30
+ <img src="https://img.shields.io/badge/status-beta-a78bfa" alt="Status: beta" />
31
+ <a href="https://github.com/shou-taro/shirube/blob/main/LICENSE"><img src="https://img.shields.io/badge/licence-AGPL--3.0-blue" alt="Licence: AGPL-3.0" /></a>
32
+ <img src="https://img.shields.io/badge/PostgreSQL-ready-336791?logo=postgresql&logoColor=white" alt="PostgreSQL" />
33
+ </p>
34
+
35
+ > 🚧 **Status: Beta.** The explorer core is here and usable today. The AI navigator — the
36
+ > feature shirube is ultimately built around — is the next milestone. shirube is pre-1.0:
37
+ > things may still change.
38
+
39
+ <p align="center"><em>See the whole database as a map.</em></p>
40
+
41
+ <p align="center">
42
+ <img src="https://raw.githubusercontent.com/shou-taro/shirube/main/docs/images/home.png" alt="shirube exploring a database: an ER diagram with a table's detail and its rows" width="960" />
43
+ </p>
44
+
45
+ ## 🤖 Why shirube
46
+
47
+ You write less SQL by hand than you used to — an AI writes much of it for you. But that
48
+ SQL still runs against **your** schema, and someone still has to understand that schema:
49
+ to prompt the AI well, to check what it gave back, to reason about where the data
50
+ actually lives. That understanding used to come for free while you wrote the queries
51
+ yourself. It doesn't any more.
52
+
53
+ shirube is where that understanding lives — and it's just as useful in the classic case,
54
+ dropping into a project with hundreds of undocumented tables and needing to find your
55
+ footing fast.
56
+
57
+ > AI changed how we write SQL. shirube changes how we understand databases.
58
+
59
+ ## 🧭 What shirube does
60
+
61
+ shirube opens on an interactive ER diagram and lets you explore a database like a map:
62
+ search for a table, focus on it, and follow its relationships outward — so you can see
63
+ how everything connects without reading DDL or writing a single query.
64
+
65
+ It is **not** another SQL IDE or database administration console — there is no query
66
+ editor, and nothing that ever writes. shirube is a tool for *understanding* a database.
67
+
68
+ ## ✨ Features
69
+
70
+ Everything below works today, in the beta:
71
+
72
+ - 🗺️ **ER diagram home** — auto-generated and centred on the most-connected table; see a
73
+ table and its immediate neighbours, and travel outward one hop at a time.
74
+ - 📋 **Table detail** — columns with their types, primary keys and nullability, plus
75
+ relationships split into *references* and *referenced by*.
76
+ - 🔗 **Relationship navigation** — click a related table to glide the map over to it.
77
+ - 👁️ **Data preview** — read a table or view's actual rows in a drawer, with click-to-sort
78
+ columns, simple filters and paging.
79
+ - ⚡ **Instant search** — jump straight to any table or column.
80
+ - 🔐 **Saved connections** — passwords kept in your operating system's keychain, never in
81
+ a config file.
82
+ - 🌗 **Light and dark themes.**
83
+
84
+ ## 🛡️ Safe by design
85
+
86
+ - 🔒 **Read-only** — every connection is opened read-only with a statement timeout. shirube
87
+ cannot modify your database — no writes, no schema changes, ever.
88
+ - 💻 **Local-first** — it runs on your machine and binds to `127.0.0.1` only. Your database
89
+ credentials and data never leave your computer.
90
+ - 📝 **Metadata-only logging** — the local log records errors and request timings, but
91
+ never the values in your data.
92
+
93
+ ## 🚀 Getting started
94
+
95
+ You'll need a PostgreSQL database to point shirube at, and a way to run a Python
96
+ application without installing it permanently.
97
+
98
+ > The beta is tested on **macOS** and **Windows**. Linux support is planned.
99
+
100
+ ```bash
101
+ pipx run shirube # with pipx
102
+ # or
103
+ uvx shirube # with uv
104
+ ```
105
+
106
+ Either command starts a small local server and opens shirube in your browser. Add a
107
+ connection with your PostgreSQL details, and shirube inspects the schema and opens the ER
108
+ diagram.
109
+
110
+ > 💡 shirube connects with whatever credentials you give it. A read-only role with
111
+ > `CONNECT` and `SELECT` is all it needs — and all it should have.
112
+
113
+ ## 📚 Learn more
114
+
115
+ - **Full README, roadmap and the AI-navigator preview** —
116
+ <https://github.com/shou-taro/shirube>
117
+ - **Contributing** —
118
+ <https://github.com/shou-taro/shirube/blob/main/CONTRIBUTING.md>
119
+ - **Licence** — [GNU AGPL-3.0](https://github.com/shou-taro/shirube/blob/main/LICENSE)
@@ -0,0 +1,103 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/shou-taro/shirube/main/docs/images/logo.svg" width="88" alt="shirube" />
3
+ </p>
4
+
5
+ <h1 align="center">shirube</h1>
6
+
7
+ <p align="center">
8
+ <strong>AI writes the SQL.<br />You still own the schema.</strong><br />
9
+ <sub>標べ — a signpost for reading a database as a map.</sub>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://github.com/shou-taro/shirube/actions/workflows/ci.yml"><img src="https://github.com/shou-taro/shirube/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
14
+ <img src="https://img.shields.io/badge/status-beta-a78bfa" alt="Status: beta" />
15
+ <a href="https://github.com/shou-taro/shirube/blob/main/LICENSE"><img src="https://img.shields.io/badge/licence-AGPL--3.0-blue" alt="Licence: AGPL-3.0" /></a>
16
+ <img src="https://img.shields.io/badge/PostgreSQL-ready-336791?logo=postgresql&logoColor=white" alt="PostgreSQL" />
17
+ </p>
18
+
19
+ > 🚧 **Status: Beta.** The explorer core is here and usable today. The AI navigator — the
20
+ > feature shirube is ultimately built around — is the next milestone. shirube is pre-1.0:
21
+ > things may still change.
22
+
23
+ <p align="center"><em>See the whole database as a map.</em></p>
24
+
25
+ <p align="center">
26
+ <img src="https://raw.githubusercontent.com/shou-taro/shirube/main/docs/images/home.png" alt="shirube exploring a database: an ER diagram with a table's detail and its rows" width="960" />
27
+ </p>
28
+
29
+ ## 🤖 Why shirube
30
+
31
+ You write less SQL by hand than you used to — an AI writes much of it for you. But that
32
+ SQL still runs against **your** schema, and someone still has to understand that schema:
33
+ to prompt the AI well, to check what it gave back, to reason about where the data
34
+ actually lives. That understanding used to come for free while you wrote the queries
35
+ yourself. It doesn't any more.
36
+
37
+ shirube is where that understanding lives — and it's just as useful in the classic case,
38
+ dropping into a project with hundreds of undocumented tables and needing to find your
39
+ footing fast.
40
+
41
+ > AI changed how we write SQL. shirube changes how we understand databases.
42
+
43
+ ## 🧭 What shirube does
44
+
45
+ shirube opens on an interactive ER diagram and lets you explore a database like a map:
46
+ search for a table, focus on it, and follow its relationships outward — so you can see
47
+ how everything connects without reading DDL or writing a single query.
48
+
49
+ It is **not** another SQL IDE or database administration console — there is no query
50
+ editor, and nothing that ever writes. shirube is a tool for *understanding* a database.
51
+
52
+ ## ✨ Features
53
+
54
+ Everything below works today, in the beta:
55
+
56
+ - 🗺️ **ER diagram home** — auto-generated and centred on the most-connected table; see a
57
+ table and its immediate neighbours, and travel outward one hop at a time.
58
+ - 📋 **Table detail** — columns with their types, primary keys and nullability, plus
59
+ relationships split into *references* and *referenced by*.
60
+ - 🔗 **Relationship navigation** — click a related table to glide the map over to it.
61
+ - 👁️ **Data preview** — read a table or view's actual rows in a drawer, with click-to-sort
62
+ columns, simple filters and paging.
63
+ - ⚡ **Instant search** — jump straight to any table or column.
64
+ - 🔐 **Saved connections** — passwords kept in your operating system's keychain, never in
65
+ a config file.
66
+ - 🌗 **Light and dark themes.**
67
+
68
+ ## 🛡️ Safe by design
69
+
70
+ - 🔒 **Read-only** — every connection is opened read-only with a statement timeout. shirube
71
+ cannot modify your database — no writes, no schema changes, ever.
72
+ - 💻 **Local-first** — it runs on your machine and binds to `127.0.0.1` only. Your database
73
+ credentials and data never leave your computer.
74
+ - 📝 **Metadata-only logging** — the local log records errors and request timings, but
75
+ never the values in your data.
76
+
77
+ ## 🚀 Getting started
78
+
79
+ You'll need a PostgreSQL database to point shirube at, and a way to run a Python
80
+ application without installing it permanently.
81
+
82
+ > The beta is tested on **macOS** and **Windows**. Linux support is planned.
83
+
84
+ ```bash
85
+ pipx run shirube # with pipx
86
+ # or
87
+ uvx shirube # with uv
88
+ ```
89
+
90
+ Either command starts a small local server and opens shirube in your browser. Add a
91
+ connection with your PostgreSQL details, and shirube inspects the schema and opens the ER
92
+ diagram.
93
+
94
+ > 💡 shirube connects with whatever credentials you give it. A read-only role with
95
+ > `CONNECT` and `SELECT` is all it needs — and all it should have.
96
+
97
+ ## 📚 Learn more
98
+
99
+ - **Full README, roadmap and the AI-navigator preview** —
100
+ <https://github.com/shou-taro/shirube>
101
+ - **Contributing** —
102
+ <https://github.com/shou-taro/shirube/blob/main/CONTRIBUTING.md>
103
+ - **Licence** — [GNU AGPL-3.0](https://github.com/shou-taro/shirube/blob/main/LICENSE)
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "shirube"
3
+ version = "0.1.0b1"
4
+ description = "Understand your database as a map — built for the AI-coding era."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "AGPL-3.0-or-later"
8
+ dependencies = [
9
+ "fastapi>=0.115",
10
+ "uvicorn[standard]>=0.51.0",
11
+ "sqlalchemy>=2.0",
12
+ "pydantic-settings>=2.4",
13
+ "platformdirs>=4.2",
14
+ "keyring>=25.7.0",
15
+ "psycopg[binary]>=3.3.4",
16
+ "structlog>=25.1.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ shirube = "shirube.__main__:main"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "ruff>=0.6",
25
+ "mypy>=1.11",
26
+ "pytest>=8.3",
27
+ "httpx>=0.27",
28
+ "bandit>=1.7",
29
+ "pip-audit>=2.7",
30
+ "hypothesis>=6.100",
31
+ "keyrings-alt>=5.0.2",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/shirube"]
40
+ # Include the built SPA (generated, git-ignored) in the wheel for single-origin serving.
41
+ artifacts = ["src/shirube/static/**/*"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ # Keep the sdist to the sources needed to build the wheel; exclude test caches,
45
+ # the lockfile and other dev cruft that hatchling would otherwise sweep in.
46
+ include = [
47
+ "src/shirube",
48
+ "README.md",
49
+ "pyproject.toml",
50
+ ]
51
+ artifacts = ["src/shirube/static/**/*"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ src = ["src", "tests"]
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "UP", "B", "N"]
59
+
60
+ [tool.mypy]
61
+ python_version = "3.11"
62
+ strict = true
63
+
64
+ [tool.pytest.ini_options]
65
+ testpaths = ["tests"]
66
+ markers = [
67
+ "integration: needs a real PostgreSQL; set SHIRUBE_TEST_DATABASE_URL to run (skipped otherwise)",
68
+ ]
@@ -0,0 +1,3 @@
1
+ """shirube — an AI-native database explorer."""
2
+
3
+ __version__ = "0.1.0b1"
@@ -0,0 +1,64 @@
1
+ """Entry point for ``shirube`` / ``uvx shirube``.
2
+
3
+ Starts the local API server (which also serves the bundled single-page app in a
4
+ packaged build) and, unless disabled, opens the browser once the server is up.
5
+ """
6
+
7
+ import threading
8
+ import webbrowser
9
+
10
+ import uvicorn
11
+
12
+ from shirube import __version__
13
+ from shirube.config import get_settings
14
+ from shirube.logging_config import setup_logging
15
+
16
+
17
+ def _is_loopback(host: str) -> bool:
18
+ """Whether ``host`` refers to the local machine only (not the network)."""
19
+ return host in {"localhost", "::1", "[::1]"} or host.startswith("127.")
20
+
21
+
22
+ def _open_browser(url: str) -> None:
23
+ """Open ``url`` in the user's default browser (best effort)."""
24
+ webbrowser.open(url)
25
+
26
+
27
+ def main() -> None:
28
+ """Launch the shirube server and open the browser.
29
+
30
+ Binds to the configured host and port (loopback by default) and blocks while
31
+ uvicorn runs. The browser launch is deferred by a short timer so it fires just
32
+ after the server starts accepting connections rather than before.
33
+ """
34
+ settings = get_settings()
35
+ logger = setup_logging()
36
+ logger.info(
37
+ "starting",
38
+ version=__version__,
39
+ host=settings.host,
40
+ port=settings.port,
41
+ data_dir=str(settings.data_dir),
42
+ )
43
+ if not _is_loopback(settings.host):
44
+ # shirube is single-user and unauthenticated by design; binding beyond loopback
45
+ # exposes an unprotected API on the network, so make an accidental one loud.
46
+ logger.warning(
47
+ "bound to a non-loopback address — shirube may be reachable from your "
48
+ "network. It is single-user and unauthenticated; bind to 127.0.0.1 unless "
49
+ "you intend to expose it.",
50
+ host=settings.host,
51
+ )
52
+ url = f"http://{settings.host}:{settings.port}"
53
+ if settings.open_browser:
54
+ # Defer the launch slightly so the server is ready to answer the first request.
55
+ threading.Timer(1.0, _open_browser, args=[url]).start()
56
+ uvicorn.run(
57
+ "shirube.adapters.api.app:app",
58
+ host=settings.host,
59
+ port=settings.port,
60
+ )
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
@@ -0,0 +1,5 @@
1
+ # adapters
2
+
3
+ Concrete implementations of the ports and the delivery mechanisms: the FastAPI API
4
+ (driving adapter), SQLAlchemy persistence, the OS keychain, database introspection,
5
+ and AI providers. Only this layer knows about frameworks and external systems.
@@ -0,0 +1 @@
1
+ """Adapters — concrete implementations of ports and delivery mechanisms."""
@@ -0,0 +1 @@
1
+ """HTTP API adapter (FastAPI)."""
@@ -0,0 +1,162 @@
1
+ """FastAPI application factory (a driving adapter).
2
+
3
+ This is the HTTP entry point into the application core. In a packaged build it also
4
+ serves the compiled single-page app, so the whole tool runs from one process on one
5
+ origin; in development the Vite dev server serves the UI and proxies API calls here.
6
+ """
7
+
8
+ import time
9
+ from collections.abc import AsyncIterator, Awaitable, Callable
10
+ from contextlib import asynccontextmanager
11
+ from pathlib import Path
12
+ from uuid import uuid4
13
+
14
+ import structlog
15
+ from fastapi import FastAPI, Request, Response
16
+ from fastapi.staticfiles import StaticFiles
17
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
18
+
19
+ from shirube import __version__
20
+ from shirube.adapters.api.errors import register_exception_handlers
21
+ from shirube.adapters.api.routes import connections, data, health, profiles, schema
22
+ from shirube.adapters.persistence.bootstrap import bootstrap_database
23
+ from shirube.config import get_settings
24
+ from shirube.logging_config import get_logger
25
+
26
+ # The built SPA, copied here by scripts/build.sh and bundled into the wheel. It is
27
+ # absent during development (git-ignored), where Vite serves the UI instead — hence the
28
+ # existence check in create_app().
29
+ STATIC_DIR = Path(__file__).resolve().parents[2] / "static"
30
+
31
+ _logger = get_logger("shirube.request")
32
+
33
+ # A conservative Content-Security-Policy for the bundled SPA. Everything is same-origin
34
+ # (scripts, styles, the API), so only 'self' is allowed; inline styles are permitted
35
+ # because React Flow sets element style attributes, and data: covers the inline SVG
36
+ # favicon and logo. frame-ancestors 'none' blocks the page being framed (clickjacking).
37
+ _CONTENT_SECURITY_POLICY = (
38
+ "default-src 'self'; "
39
+ "script-src 'self'; "
40
+ "style-src 'self' 'unsafe-inline'; "
41
+ "img-src 'self' data:; "
42
+ "font-src 'self' data:; "
43
+ "connect-src 'self'; "
44
+ "base-uri 'self'; "
45
+ "form-action 'self'; "
46
+ "frame-ancestors 'none'"
47
+ )
48
+
49
+
50
+ @asynccontextmanager
51
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
52
+ """Run start-up and shutdown work around the application's lifetime.
53
+
54
+ On start-up the local app-state database is created if missing. There is nothing to
55
+ tear down yet, so control simply yields back to the server.
56
+
57
+ Args:
58
+ app: The FastAPI application (unused; required by the lifespan signature).
59
+
60
+ Yields:
61
+ None, once start-up has completed.
62
+ """
63
+ bootstrap_database()
64
+ yield
65
+
66
+
67
+ def create_app() -> FastAPI:
68
+ """Build and configure the FastAPI application.
69
+
70
+ Wires up exception handling and the API routes, and — when the SPA has been built
71
+ and bundled — mounts it at the root so the UI and API share a single origin.
72
+
73
+ Returns:
74
+ The configured application instance.
75
+ """
76
+ app = FastAPI(title="shirube", version=__version__, lifespan=lifespan)
77
+
78
+ @app.middleware("http")
79
+ async def _security_headers(
80
+ request: Request,
81
+ call_next: Callable[[Request], Awaitable[Response]],
82
+ ) -> Response:
83
+ """Add defence-in-depth security headers to every response.
84
+
85
+ Cheap hardening for a locally served app: stop MIME sniffing, forbid framing
86
+ (clickjacking), withhold the referrer, and constrain resource loading to the
87
+ same origin via a Content-Security-Policy.
88
+ """
89
+ response = await call_next(request)
90
+ response.headers["X-Content-Type-Options"] = "nosniff"
91
+ response.headers["X-Frame-Options"] = "DENY"
92
+ response.headers["Referrer-Policy"] = "no-referrer"
93
+ response.headers["Content-Security-Policy"] = _CONTENT_SECURITY_POLICY
94
+ return response
95
+
96
+ @app.middleware("http")
97
+ async def _log_requests(
98
+ request: Request,
99
+ call_next: Callable[[Request], Awaitable[Response]],
100
+ ) -> Response:
101
+ """Log each request's outcome — method, path, status and duration.
102
+
103
+ A short ``request_id`` is bound for the lifetime of the request and attached to
104
+ every event logged while it runs (here and in the error handler), so the lines
105
+ belonging to one request can be tied together; it is also returned to the caller
106
+ in the ``X-Request-ID`` header. Only metadata is recorded: never the query string,
107
+ request body or response content, so filter values and row data stay out of the
108
+ log. An exception that escapes the handlers (a genuine bug) is logged with its
109
+ traceback and re-raised for FastAPI's default 500.
110
+ """
111
+ request_id = uuid4().hex[:12]
112
+ structlog.contextvars.bind_contextvars(request_id=request_id)
113
+ start = time.perf_counter()
114
+ try:
115
+ response = await call_next(request)
116
+ elapsed_ms = (time.perf_counter() - start) * 1000
117
+ _logger.info(
118
+ "request",
119
+ method=request.method,
120
+ path=request.url.path,
121
+ status=response.status_code,
122
+ duration_ms=round(elapsed_ms),
123
+ )
124
+ response.headers["X-Request-ID"] = request_id
125
+ return response
126
+ except Exception:
127
+ elapsed_ms = (time.perf_counter() - start) * 1000
128
+ _logger.exception(
129
+ "request_failed",
130
+ method=request.method,
131
+ path=request.url.path,
132
+ duration_ms=round(elapsed_ms),
133
+ )
134
+ raise
135
+ finally:
136
+ # Always clear the request-scoped context so it never bleeds into the next
137
+ # request handled on this task.
138
+ structlog.contextvars.unbind_contextvars("request_id")
139
+
140
+ # Reject requests whose Host header is not a name we serve, added last so it runs
141
+ # outermost — a bad host is refused before any handler or logging runs. This is the
142
+ # core DNS-rebinding defence: it stops a page on another origin from reaching this
143
+ # local API by pointing its own hostname at 127.0.0.1. The bind host is always
144
+ # allowed alongside the configured names.
145
+ settings = get_settings()
146
+ allowed_hosts = list(dict.fromkeys([*settings.allowed_hosts, settings.host]))
147
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts)
148
+
149
+ register_exception_handlers(app)
150
+ app.include_router(health.router, prefix="/api")
151
+ app.include_router(profiles.router, prefix="/api")
152
+ app.include_router(connections.router, prefix="/api")
153
+ app.include_router(schema.router, prefix="/api")
154
+ app.include_router(data.router, prefix="/api")
155
+ if STATIC_DIR.is_dir():
156
+ # Mounted after the API router so "/api/*" matches first; this then catches
157
+ # every remaining path and serves index.html for "/".
158
+ app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="spa")
159
+ return app
160
+
161
+
162
+ app = create_app()