flask-fenrir 0.1.0__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.
@@ -0,0 +1 @@
1
+ export PYPI_TOKEN=pypi-AgEIcHlwaS5vcmcCJDg5MWY3OTBmLWYwNWEtNDU3OS05NDE4LTk1MDc1OWM4NDU2MAACKlszLCIzY2JiOTk5Ny0wMDg0LTQ3M2MtODE3Zi02YWZkZTRlMjllMmIiXQAABiBF2S2fNYfpL_9SrG9_YxQJ80z5yFYP161OOn1MS-IkSw
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fenrir
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: flask-fenrir
3
+ Version: 0.1.0
4
+ Summary: Drop-in Flask API for LLM agent access to SQLModel web apps
5
+ Project-URL: Repository, https://github.com/fenrir/fenrir-api
6
+ Author: Fenrir
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: api,flask,llm,sqlalchemy,sqlmodel
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Flask
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: flask>=3.0
17
+ Requires-Dist: sqlalchemy>=2.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # flask-fenrir
21
+
22
+ Drop-in Flask API for LLM agent access to SQLModel web apps. One install, two lines of code, one env var.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ uv add flask-fenrir
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ from flask_fenrir import create_fenrir_bp
34
+
35
+ app.register_blueprint(create_fenrir_bp(engine))
36
+ ```
37
+
38
+ ```python
39
+ from flask_fenrir import secure_app
40
+
41
+ secure_app(app) # optional — adds basic auth to the whole app
42
+ ```
43
+
44
+ ```bash
45
+ dokku config:set myapp FENRIR_API_KEY=$(openssl rand -hex 32)
46
+ ```
47
+
48
+ That's it. One env var for everything — bearer token for LLM agents, basic auth password for browser access.
49
+
50
+ ## What it does
51
+
52
+ Registers a `/fenrir/` blueprint with four endpoints, all behind bearer token auth:
53
+
54
+ | Endpoint | Method | Description |
55
+ |---|---|---|
56
+ | `/fenrir/` | GET | App info, FENRIR.md contents, table list with row counts |
57
+ | `/fenrir/schema` | GET | Full schema introspection (columns, types, PKs, FKs, indexes) |
58
+ | `/fenrir/query` | POST | Read-only SQL (`SELECT` / `WITH ... SELECT`), returns rows as JSON |
59
+
60
+ ### Authentication
61
+
62
+ `FENRIR_API_KEY` is the single secret for your app.
63
+
64
+ **Fenrir endpoints** (`/fenrir/*`) require `Authorization: Bearer <key>`.
65
+
66
+ **`secure_app(app)`** (optional) adds basic auth to all other routes — any username, password must match `FENRIR_API_KEY`. This replaces Dokku's `http-auth` so everything is managed in one place.
67
+
68
+ Both are skipped when `FLASK_DEBUG` is on. If the env var isn't set, everything returns 401/503 (fail closed).
69
+
70
+ ```python
71
+ # Options:
72
+ secure_app(app) # basic auth on all routes
73
+ secure_app(app, skip_paths=["/health", "/webhook"]) # skip specific paths
74
+ secure_app(app, api_key_auth={ # also accept a separate API key
75
+ "header": "X-API-Key",
76
+ "secret": os.getenv("API_KEY"),
77
+ })
78
+ ```
79
+
80
+ ### FENRIR.md
81
+
82
+ Write a `FENRIR.md` at your project root with domain context for the LLM:
83
+
84
+ ```markdown
85
+ # My App
86
+
87
+ A brief description of what this app does.
88
+
89
+ ## Domain concepts
90
+
91
+ - **Widget** — the core thing users create. Has a `status` field: draft, active, archived.
92
+ - **Team** — groups of users. The `owner_id` is always a user, not another team.
93
+
94
+ ## Useful queries
95
+
96
+ - Active widgets by team: `SELECT t.name, COUNT(*) FROM widget w JOIN team t ON ...`
97
+ ```
98
+
99
+ The `/fenrir/` endpoint serves this content so the LLM can understand your app before querying it.
100
+
101
+ #### Generating FENRIR.md with an LLM
102
+
103
+ If the app is running locally with flask-fenrir enabled, you can have an LLM generate the file. Run this prompt from the app's project root:
104
+
105
+ ```
106
+ This app uses flask-fenrir — a small Flask blueprint that exposes the
107
+ database to LLM agents via REST (schema introspection, read/write SQL).
108
+ It serves a FENRIR.md file at GET /fenrir/ to give the LLM domain
109
+ context it can't infer from the schema alone. That's the file you're
110
+ writing now.
111
+
112
+ The API is at /fenrir/.
113
+
114
+ Read the codebase to understand the app's models, business logic, and
115
+ domain. Then hit GET /fenrir/schema and POST /fenrir/query to see what
116
+ the database actually looks like from Fenrir POV.
117
+
118
+ Compare what you learned from the code with what the schema and data
119
+ show. Write a FENRIR.md that bridges the gap — focus on things an LLM
120
+ querying the database blind would NOT be able to figure out from the
121
+ schema alone:
122
+ - What the app actually does (one-liner)
123
+ - What each table/field means in business terms
124
+ - Non-obvious values: enums, flags, status codes, soft deletes, fields
125
+ whose names are misleading
126
+ - How tables relate to each other (especially implicit relationships
127
+ not captured by foreign keys)
128
+ - Common useful queries for an operator
129
+ - Gotchas and edge cases
130
+
131
+ Don't repeat what's already obvious from column names and types.
132
+ An LLM can read a schema — FENRIR.md should add the context it can't
133
+ infer.
134
+
135
+ Ask me questions if anything in the code or data is unclear. Don't
136
+ guess at business logic — it's better to ask than to document something
137
+ wrong.
138
+
139
+ This file will need to be maintained alongside the codebase. Keep it concise
140
+ and structured so it's easy to update when models change. If a section
141
+ would go stale quickly, leave it out or note what to watch for.
142
+ ```
143
+
144
+ ### Row limit
145
+
146
+ By default, `/fenrir/query` caps results at 1000 rows. Override it:
147
+
148
+ ```python
149
+ app.register_blueprint(create_fenrir_bp(engine, row_limit=500))
150
+ ```
151
+
152
+ ## Dependencies
153
+
154
+ Just Flask (≥3.0) and SQLAlchemy (≥2.0). Works with any Flask + SQLAlchemy app — SQLModel not required at runtime.
155
+
156
+ ## License
157
+
158
+ MIT
@@ -0,0 +1,139 @@
1
+ # flask-fenrir
2
+
3
+ Drop-in Flask API for LLM agent access to SQLModel web apps. One install, two lines of code, one env var.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ uv add flask-fenrir
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from flask_fenrir import create_fenrir_bp
15
+
16
+ app.register_blueprint(create_fenrir_bp(engine))
17
+ ```
18
+
19
+ ```python
20
+ from flask_fenrir import secure_app
21
+
22
+ secure_app(app) # optional — adds basic auth to the whole app
23
+ ```
24
+
25
+ ```bash
26
+ dokku config:set myapp FENRIR_API_KEY=$(openssl rand -hex 32)
27
+ ```
28
+
29
+ That's it. One env var for everything — bearer token for LLM agents, basic auth password for browser access.
30
+
31
+ ## What it does
32
+
33
+ Registers a `/fenrir/` blueprint with four endpoints, all behind bearer token auth:
34
+
35
+ | Endpoint | Method | Description |
36
+ |---|---|---|
37
+ | `/fenrir/` | GET | App info, FENRIR.md contents, table list with row counts |
38
+ | `/fenrir/schema` | GET | Full schema introspection (columns, types, PKs, FKs, indexes) |
39
+ | `/fenrir/query` | POST | Read-only SQL (`SELECT` / `WITH ... SELECT`), returns rows as JSON |
40
+
41
+ ### Authentication
42
+
43
+ `FENRIR_API_KEY` is the single secret for your app.
44
+
45
+ **Fenrir endpoints** (`/fenrir/*`) require `Authorization: Bearer <key>`.
46
+
47
+ **`secure_app(app)`** (optional) adds basic auth to all other routes — any username, password must match `FENRIR_API_KEY`. This replaces Dokku's `http-auth` so everything is managed in one place.
48
+
49
+ Both are skipped when `FLASK_DEBUG` is on. If the env var isn't set, everything returns 401/503 (fail closed).
50
+
51
+ ```python
52
+ # Options:
53
+ secure_app(app) # basic auth on all routes
54
+ secure_app(app, skip_paths=["/health", "/webhook"]) # skip specific paths
55
+ secure_app(app, api_key_auth={ # also accept a separate API key
56
+ "header": "X-API-Key",
57
+ "secret": os.getenv("API_KEY"),
58
+ })
59
+ ```
60
+
61
+ ### FENRIR.md
62
+
63
+ Write a `FENRIR.md` at your project root with domain context for the LLM:
64
+
65
+ ```markdown
66
+ # My App
67
+
68
+ A brief description of what this app does.
69
+
70
+ ## Domain concepts
71
+
72
+ - **Widget** — the core thing users create. Has a `status` field: draft, active, archived.
73
+ - **Team** — groups of users. The `owner_id` is always a user, not another team.
74
+
75
+ ## Useful queries
76
+
77
+ - Active widgets by team: `SELECT t.name, COUNT(*) FROM widget w JOIN team t ON ...`
78
+ ```
79
+
80
+ The `/fenrir/` endpoint serves this content so the LLM can understand your app before querying it.
81
+
82
+ #### Generating FENRIR.md with an LLM
83
+
84
+ If the app is running locally with flask-fenrir enabled, you can have an LLM generate the file. Run this prompt from the app's project root:
85
+
86
+ ```
87
+ This app uses flask-fenrir — a small Flask blueprint that exposes the
88
+ database to LLM agents via REST (schema introspection, read/write SQL).
89
+ It serves a FENRIR.md file at GET /fenrir/ to give the LLM domain
90
+ context it can't infer from the schema alone. That's the file you're
91
+ writing now.
92
+
93
+ The API is at /fenrir/.
94
+
95
+ Read the codebase to understand the app's models, business logic, and
96
+ domain. Then hit GET /fenrir/schema and POST /fenrir/query to see what
97
+ the database actually looks like from Fenrir POV.
98
+
99
+ Compare what you learned from the code with what the schema and data
100
+ show. Write a FENRIR.md that bridges the gap — focus on things an LLM
101
+ querying the database blind would NOT be able to figure out from the
102
+ schema alone:
103
+ - What the app actually does (one-liner)
104
+ - What each table/field means in business terms
105
+ - Non-obvious values: enums, flags, status codes, soft deletes, fields
106
+ whose names are misleading
107
+ - How tables relate to each other (especially implicit relationships
108
+ not captured by foreign keys)
109
+ - Common useful queries for an operator
110
+ - Gotchas and edge cases
111
+
112
+ Don't repeat what's already obvious from column names and types.
113
+ An LLM can read a schema — FENRIR.md should add the context it can't
114
+ infer.
115
+
116
+ Ask me questions if anything in the code or data is unclear. Don't
117
+ guess at business logic — it's better to ask than to document something
118
+ wrong.
119
+
120
+ This file will need to be maintained alongside the codebase. Keep it concise
121
+ and structured so it's easy to update when models change. If a section
122
+ would go stale quickly, leave it out or note what to watch for.
123
+ ```
124
+
125
+ ### Row limit
126
+
127
+ By default, `/fenrir/query` caps results at 1000 rows. Override it:
128
+
129
+ ```python
130
+ app.register_blueprint(create_fenrir_bp(engine, row_limit=500))
131
+ ```
132
+
133
+ ## Dependencies
134
+
135
+ Just Flask (≥3.0) and SQLAlchemy (≥2.0). Works with any Flask + SQLAlchemy app — SQLModel not required at runtime.
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,257 @@
1
+ """
2
+ fenrir-api — Drop-in Flask API for LLM agent access to SQLModel web apps.
3
+
4
+ Usage:
5
+ from flask_fenrir import create_fenrir_bp, secure_app
6
+
7
+ app.register_blueprint(create_fenrir_bp(engine))
8
+ secure_app(app)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from functools import wraps
16
+ from pathlib import Path
17
+
18
+ from flask import Blueprint, Flask, Response, current_app, jsonify, request
19
+ from sqlalchemy import inspect, text
20
+ from sqlalchemy.engine import Engine
21
+
22
+ __all__ = ["create_fenrir_bp", "secure_app"]
23
+
24
+ DEFAULT_ROW_LIMIT = 1000
25
+
26
+ # Matches SELECT or WITH ... SELECT (the only read-only shapes we allow)
27
+ _READ_ONLY_RE = re.compile(
28
+ r"^\s*(SELECT|WITH\s)", re.IGNORECASE | re.DOTALL
29
+ )
30
+
31
+
32
+ def _require_auth(f):
33
+ """Reject requests unless Authorization header matches FENRIR_API_KEY.
34
+
35
+ Auth is skipped entirely when the Flask app has DEBUG enabled.
36
+ """
37
+
38
+ @wraps(f)
39
+ def wrapper(*args, **kwargs):
40
+ if current_app.debug:
41
+ return f(*args, **kwargs)
42
+
43
+ api_key = os.environ.get("FENRIR_API_KEY")
44
+ if not api_key:
45
+ return jsonify({"error": "FENRIR_API_KEY not configured"}), 401
46
+
47
+ auth = request.headers.get("Authorization", "")
48
+ if not auth.startswith("Bearer ") or auth[7:] != api_key:
49
+ return jsonify({"error": "unauthorized"}), 401
50
+
51
+ return f(*args, **kwargs)
52
+
53
+ return wrapper
54
+
55
+
56
+ def secure_app(
57
+ app: Flask,
58
+ *,
59
+ skip_paths: list[str] | None = None,
60
+ api_key_auth: dict[str, str | None] | None = None,
61
+ ) -> None:
62
+ """Add basic auth to all routes except /fenrir/ and static files.
63
+
64
+ Uses FENRIR_API_KEY as the password (any username accepted).
65
+ Skipped entirely when app.debug is True.
66
+
67
+ Args:
68
+ app: Flask application.
69
+ skip_paths: Additional path prefixes to skip auth for (e.g. ["/health"]).
70
+ api_key_auth: Optional dict with "header" and "secret" keys for API key
71
+ auth (e.g. {"header": "X-API-Key", "secret": os.getenv("API_KEY")}).
72
+ Allows programmatic access (MCP, etc.) with a separate secret.
73
+ """
74
+ _skip = ["/fenrir/", "/static/"]
75
+ if skip_paths:
76
+ _skip.extend(skip_paths)
77
+
78
+ @app.before_request
79
+ def _basic_auth_check():
80
+ if app.debug:
81
+ return
82
+
83
+ # Skip excluded paths
84
+ path = request.path
85
+ if path == "/" or any(path.startswith(p) for p in _skip):
86
+ return
87
+ if request.endpoint == "static":
88
+ return
89
+
90
+ api_key = os.environ.get("FENRIR_API_KEY")
91
+ if not api_key:
92
+ return Response("Not configured", 503)
93
+
94
+ # Check API key header if configured (for MCP / programmatic access)
95
+ if api_key_auth and api_key_auth.get("secret"):
96
+ header_val = request.headers.get(api_key_auth.get("header", ""))
97
+ if header_val and header_val == api_key_auth["secret"]:
98
+ return
99
+
100
+ # Check basic auth — any username, password must match FENRIR_API_KEY
101
+ auth = request.authorization
102
+ if auth and auth.password == api_key:
103
+ return
104
+
105
+ return Response(
106
+ "Authentication required",
107
+ 401,
108
+ {"WWW-Authenticate": 'Basic realm="Login"'},
109
+ )
110
+
111
+
112
+ def _read_fenrir_md() -> str | None:
113
+ """Read FENRIR.md from the app's root directory."""
114
+ root = Path(current_app.root_path)
115
+ # Walk up at most two levels — root_path is often the package dir,
116
+ # FENRIR.md lives at the project root (next to pyproject.toml).
117
+ for base in [root, root.parent, root.parent.parent]:
118
+ candidate = base / "FENRIR.md"
119
+ if candidate.is_file():
120
+ return candidate.read_text(encoding="utf-8")
121
+ return None
122
+
123
+
124
+ def _extract_app_name(md: str | None, app_name: str) -> str:
125
+ """Pull the first heading from FENRIR.md, or fall back to Flask app name."""
126
+ if md:
127
+ for line in md.splitlines():
128
+ stripped = line.strip()
129
+ if stripped.startswith("#"):
130
+ return stripped.lstrip("#").strip()
131
+ return app_name
132
+
133
+
134
+ def create_fenrir_bp(engine: Engine, *, row_limit: int = DEFAULT_ROW_LIMIT) -> Blueprint:
135
+ """Create and return the Fenrir API blueprint.
136
+
137
+ Args:
138
+ engine: SQLAlchemy engine to introspect and query.
139
+ row_limit: Max rows returned by /fenrir/query (default 1000).
140
+ """
141
+ bp = Blueprint("fenrir", __name__, url_prefix="/fenrir")
142
+
143
+ # -- GET /fenrir/ ----------------------------------------------------------
144
+
145
+ @bp.route("/")
146
+ @_require_auth
147
+ def index():
148
+ md = _read_fenrir_md()
149
+ app_name = _extract_app_name(md, current_app.name)
150
+
151
+ # Table list with row counts
152
+ insp = inspect(engine)
153
+ tables = []
154
+ with engine.connect() as conn:
155
+ for table_name in sorted(insp.get_table_names()):
156
+ count = conn.execute(
157
+ text(f'SELECT COUNT(*) FROM "{table_name}"')
158
+ ).scalar()
159
+ tables.append({"name": table_name, "row_count": count})
160
+
161
+ return jsonify({
162
+ "app": app_name,
163
+ "fenrir_md": md,
164
+ "tables": tables,
165
+ })
166
+
167
+ # -- GET /fenrir/schema ----------------------------------------------------
168
+
169
+ @bp.route("/schema")
170
+ @_require_auth
171
+ def schema():
172
+ insp = inspect(engine)
173
+ tables = {}
174
+
175
+ for table_name in sorted(insp.get_table_names()):
176
+ columns = []
177
+ for col in insp.get_columns(table_name):
178
+ columns.append({
179
+ "name": col["name"],
180
+ "type": str(col["type"]),
181
+ "nullable": col.get("nullable", True),
182
+ "default": str(col["default"]) if col.get("default") is not None else None,
183
+ })
184
+
185
+ pk = insp.get_pk_constraint(table_name)
186
+ fks = [
187
+ {
188
+ "columns": fk["constrained_columns"],
189
+ "referred_table": fk["referred_table"],
190
+ "referred_columns": fk["referred_columns"],
191
+ }
192
+ for fk in insp.get_foreign_keys(table_name)
193
+ ]
194
+ indexes = [
195
+ {
196
+ "name": idx["name"],
197
+ "columns": idx["column_names"],
198
+ "unique": idx["unique"],
199
+ }
200
+ for idx in insp.get_indexes(table_name)
201
+ ]
202
+
203
+ tables[table_name] = {
204
+ "columns": columns,
205
+ "primary_key": pk.get("constrained_columns", []) if pk else [],
206
+ "foreign_keys": fks,
207
+ "indexes": indexes,
208
+ }
209
+
210
+ return jsonify({"tables": tables})
211
+
212
+ # -- POST /fenrir/query ----------------------------------------------------
213
+
214
+ @bp.route("/query", methods=["POST"])
215
+ @_require_auth
216
+ def query():
217
+ body = request.get_json(silent=True) or {}
218
+ sql = body.get("sql", "").strip()
219
+
220
+ if not sql:
221
+ return jsonify({"error": "missing 'sql' field"}), 400
222
+
223
+ if not _READ_ONLY_RE.match(sql):
224
+ return jsonify({"error": "only SELECT (or WITH ... SELECT) allowed"}), 400
225
+
226
+ try:
227
+ with engine.connect().execution_options(
228
+ postgresql_readonly=True, # PostgreSQL
229
+ sqlite_raw_colnames=True, # harmless on SQLite
230
+ ) as conn:
231
+ conn.begin()
232
+ # SET TRANSACTION READ ONLY works on PostgreSQL.
233
+ # SQLite doesn't support it, so we skip errors silently.
234
+ try:
235
+ conn.execute(text("SET TRANSACTION READ ONLY"))
236
+ except Exception:
237
+ pass
238
+ result = conn.execute(text(sql))
239
+ columns = list(result.keys())
240
+ rows = [list(r) for r in result.fetchmany(row_limit)]
241
+ total = len(rows)
242
+ # Check if there were more rows we didn't fetch
243
+ extra = result.fetchone()
244
+ truncated = extra is not None
245
+ conn.rollback()
246
+ except Exception as exc:
247
+ return jsonify({"error": str(exc)}), 422
248
+
249
+ return jsonify({
250
+ "columns": columns,
251
+ "rows": rows,
252
+ "row_count": total,
253
+ "truncated": truncated,
254
+ "row_limit": row_limit,
255
+ })
256
+
257
+ return bp
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "flask-fenrir"
3
+ version = "0.1.0"
4
+ description = "Drop-in Flask API for LLM agent access to SQLModel web apps"
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.12"
8
+ authors = [{name = "Fenrir"}]
9
+ keywords = ["flask", "llm", "api", "sqlmodel", "sqlalchemy"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Framework :: Flask",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ ]
17
+ dependencies = [
18
+ "flask>=3.0",
19
+ "sqlalchemy>=2.0",
20
+ ]
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/fenrir/fenrir-api"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ only-include = ["flask_fenrir.py"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "pytest>=8.0",
38
+ "sqlmodel>=0.0.22",
39
+ ]
@@ -0,0 +1,415 @@
1
+ """Tests for fenrir-api using a minimal Flask + SQLModel app with SQLite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+ from flask import Flask
12
+ from sqlalchemy import create_engine
13
+ from sqlmodel import Field, Session, SQLModel
14
+
15
+ from flask_fenrir import create_fenrir_bp, secure_app
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Models
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ class Author(SQLModel, table=True):
23
+ id: int | None = Field(default=None, primary_key=True)
24
+ name: str
25
+ email: str | None = None
26
+
27
+
28
+ class Book(SQLModel, table=True):
29
+ id: int | None = Field(default=None, primary_key=True)
30
+ title: str
31
+ author_id: int = Field(foreign_key="author.id")
32
+ pages: int | None = None
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Fixtures
37
+ # ---------------------------------------------------------------------------
38
+
39
+ API_KEY = "test-secret-key"
40
+
41
+
42
+ @pytest.fixture()
43
+ def app(tmp_path: Path):
44
+ """Create a minimal Flask app with an in-memory SQLite DB."""
45
+ # Write a FENRIR.md next to the app
46
+ fenrir_md = tmp_path / "FENRIR.md"
47
+ fenrir_md.write_text("# Bookstore\n\nA tiny bookstore app.\n")
48
+
49
+ engine = create_engine("sqlite://", echo=False)
50
+ SQLModel.metadata.create_all(engine)
51
+
52
+ # Seed data
53
+ with Session(engine) as session:
54
+ a = Author(name="Tolkien", email="jrr@shire.nz")
55
+ session.add(a)
56
+ session.commit()
57
+ session.refresh(a)
58
+ session.add(Book(title="The Hobbit", author_id=a.id, pages=310))
59
+ session.commit()
60
+
61
+ app = Flask(__name__, root_path=str(tmp_path))
62
+ app.config["TESTING"] = True
63
+ app.register_blueprint(create_fenrir_bp(engine))
64
+
65
+ os.environ["FENRIR_API_KEY"] = API_KEY
66
+ yield app
67
+ os.environ.pop("FENRIR_API_KEY", None)
68
+
69
+
70
+ @pytest.fixture()
71
+ def client(app):
72
+ return app.test_client()
73
+
74
+
75
+ def auth_headers():
76
+ return {"Authorization": f"Bearer {API_KEY}"}
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Auth tests
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ class TestAuth:
85
+ def test_missing_header(self, client):
86
+ r = client.get("/fenrir/")
87
+ assert r.status_code == 401
88
+
89
+ def test_wrong_key(self, client):
90
+ r = client.get("/fenrir/", headers={"Authorization": "Bearer wrong"})
91
+ assert r.status_code == 401
92
+
93
+ def test_correct_key(self, client):
94
+ r = client.get("/fenrir/", headers=auth_headers())
95
+ assert r.status_code == 200
96
+
97
+ def test_no_env_var(self, client):
98
+ os.environ.pop("FENRIR_API_KEY", None)
99
+ r = client.get("/fenrir/", headers={"Authorization": "Bearer anything"})
100
+ assert r.status_code == 401
101
+
102
+ def test_debug_mode_skips_auth(self, app):
103
+ app.debug = True
104
+ with app.test_client() as c:
105
+ r = c.get("/fenrir/")
106
+ assert r.status_code == 200
107
+
108
+ def test_debug_mode_skips_auth_even_without_env_var(self, app):
109
+ os.environ.pop("FENRIR_API_KEY", None)
110
+ app.debug = True
111
+ with app.test_client() as c:
112
+ r = c.get("/fenrir/")
113
+ assert r.status_code == 200
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # GET /fenrir/
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ class TestIndex:
122
+ def test_returns_app_info(self, client):
123
+ r = client.get("/fenrir/", headers=auth_headers())
124
+ data = r.get_json()
125
+ assert data["app"] == "Bookstore"
126
+ assert "Bookstore" in data["fenrir_md"]
127
+ assert any(t["name"] == "author" for t in data["tables"])
128
+ assert any(t["name"] == "book" for t in data["tables"])
129
+
130
+ def test_row_counts(self, client):
131
+ r = client.get("/fenrir/", headers=auth_headers())
132
+ data = r.get_json()
133
+ authors = next(t for t in data["tables"] if t["name"] == "author")
134
+ books = next(t for t in data["tables"] if t["name"] == "book")
135
+ assert authors["row_count"] == 1
136
+ assert books["row_count"] == 1
137
+
138
+ def test_no_fenrir_md(self, tmp_path):
139
+ """When FENRIR.md doesn't exist, fenrir_md should be null."""
140
+ empty_dir = tmp_path / "empty"
141
+ empty_dir.mkdir()
142
+ engine = create_engine("sqlite://")
143
+ SQLModel.metadata.create_all(engine)
144
+
145
+ app = Flask(__name__, root_path=str(empty_dir))
146
+ app.config["TESTING"] = True
147
+ app.register_blueprint(create_fenrir_bp(engine))
148
+
149
+ os.environ["FENRIR_API_KEY"] = API_KEY
150
+ with app.test_client() as c:
151
+ r = c.get("/fenrir/", headers=auth_headers())
152
+ data = r.get_json()
153
+ assert data["fenrir_md"] is None
154
+ # Falls back to Flask app name
155
+ assert data["app"] is not None
156
+ os.environ.pop("FENRIR_API_KEY", None)
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # GET /fenrir/schema
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ class TestSchema:
165
+ def test_returns_tables(self, client):
166
+ r = client.get("/fenrir/schema", headers=auth_headers())
167
+ data = r.get_json()
168
+ assert "author" in data["tables"]
169
+ assert "book" in data["tables"]
170
+
171
+ def test_column_info(self, client):
172
+ r = client.get("/fenrir/schema", headers=auth_headers())
173
+ author = r.get_json()["tables"]["author"]
174
+ col_names = [c["name"] for c in author["columns"]]
175
+ assert "id" in col_names
176
+ assert "name" in col_names
177
+ assert "email" in col_names
178
+
179
+ def test_primary_key(self, client):
180
+ r = client.get("/fenrir/schema", headers=auth_headers())
181
+ author = r.get_json()["tables"]["author"]
182
+ assert "id" in author["primary_key"]
183
+
184
+ def test_foreign_keys(self, client):
185
+ r = client.get("/fenrir/schema", headers=auth_headers())
186
+ book = r.get_json()["tables"]["book"]
187
+ assert len(book["foreign_keys"]) == 1
188
+ fk = book["foreign_keys"][0]
189
+ assert fk["referred_table"] == "author"
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # POST /fenrir/query
194
+ # ---------------------------------------------------------------------------
195
+
196
+
197
+ class TestQuery:
198
+ def test_select(self, client):
199
+ r = client.post(
200
+ "/fenrir/query",
201
+ json={"sql": "SELECT name FROM author"},
202
+ headers=auth_headers(),
203
+ )
204
+ data = r.get_json()
205
+ assert data["columns"] == ["name"]
206
+ assert data["rows"] == [["Tolkien"]]
207
+ assert data["row_count"] == 1
208
+ assert data["truncated"] is False
209
+
210
+ def test_with_select(self, client):
211
+ r = client.post(
212
+ "/fenrir/query",
213
+ json={"sql": "WITH a AS (SELECT * FROM author) SELECT name FROM a"},
214
+ headers=auth_headers(),
215
+ )
216
+ assert r.status_code == 200
217
+ assert r.get_json()["row_count"] == 1
218
+
219
+ def test_rejects_insert(self, client):
220
+ r = client.post(
221
+ "/fenrir/query",
222
+ json={"sql": "INSERT INTO author (name) VALUES ('nope')"},
223
+ headers=auth_headers(),
224
+ )
225
+ assert r.status_code == 400
226
+
227
+ def test_rejects_delete(self, client):
228
+ r = client.post(
229
+ "/fenrir/query",
230
+ json={"sql": "DELETE FROM author"},
231
+ headers=auth_headers(),
232
+ )
233
+ assert r.status_code == 400
234
+
235
+ def test_missing_sql(self, client):
236
+ r = client.post("/fenrir/query", json={}, headers=auth_headers())
237
+ assert r.status_code == 400
238
+
239
+ def test_row_limit(self, tmp_path):
240
+ """Row limit caps the number of returned rows."""
241
+ engine = create_engine("sqlite://")
242
+ SQLModel.metadata.create_all(engine)
243
+
244
+ with Session(engine) as session:
245
+ for i in range(10):
246
+ session.add(Author(name=f"Author {i}"))
247
+ session.commit()
248
+
249
+ app = Flask(__name__, root_path=str(tmp_path))
250
+ app.config["TESTING"] = True
251
+ app.register_blueprint(create_fenrir_bp(engine, row_limit=3))
252
+
253
+ os.environ["FENRIR_API_KEY"] = API_KEY
254
+ with app.test_client() as c:
255
+ r = c.post(
256
+ "/fenrir/query",
257
+ json={"sql": "SELECT * FROM author"},
258
+ headers=auth_headers(),
259
+ )
260
+ data = r.get_json()
261
+ assert data["row_count"] == 3
262
+ assert data["truncated"] is True
263
+ assert data["row_limit"] == 3
264
+ os.environ.pop("FENRIR_API_KEY", None)
265
+
266
+ def test_bad_sql(self, client):
267
+ r = client.post(
268
+ "/fenrir/query",
269
+ json={"sql": "SELECT * FROM nonexistent"},
270
+ headers=auth_headers(),
271
+ )
272
+ assert r.status_code == 422
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # secure_app
277
+ # ---------------------------------------------------------------------------
278
+
279
+
280
+ class TestSecureApp:
281
+ """Tests for the secure_app() basic auth middleware."""
282
+
283
+ @pytest.fixture()
284
+ def secured_app(self, tmp_path):
285
+ engine = create_engine("sqlite://")
286
+ SQLModel.metadata.create_all(engine)
287
+
288
+ app = Flask(__name__, root_path=str(tmp_path))
289
+ app.config["TESTING"] = True
290
+ app.register_blueprint(create_fenrir_bp(engine))
291
+ secure_app(app)
292
+
293
+ @app.route("/dashboard")
294
+ def dashboard():
295
+ return "ok"
296
+
297
+ @app.route("/api/data")
298
+ def api_data():
299
+ return "ok"
300
+
301
+ os.environ["FENRIR_API_KEY"] = API_KEY
302
+ yield app
303
+ os.environ.pop("FENRIR_API_KEY", None)
304
+
305
+ @pytest.fixture()
306
+ def secured_client(self, secured_app):
307
+ return secured_app.test_client()
308
+
309
+ def _basic_auth_headers(self, password=API_KEY, username="anything"):
310
+ import base64
311
+ creds = base64.b64encode(f"{username}:{password}".encode()).decode()
312
+ return {"Authorization": f"Basic {creds}"}
313
+
314
+ # -- Basic auth on app routes --
315
+
316
+ def test_app_route_blocked_without_auth(self, secured_client):
317
+ r = secured_client.get("/dashboard")
318
+ assert r.status_code == 401
319
+ assert "WWW-Authenticate" in r.headers
320
+
321
+ def test_app_route_allowed_with_basic_auth(self, secured_client):
322
+ r = secured_client.get("/dashboard", headers=self._basic_auth_headers())
323
+ assert r.status_code == 200
324
+
325
+ def test_app_route_wrong_password(self, secured_client):
326
+ r = secured_client.get("/dashboard", headers=self._basic_auth_headers(password="wrong"))
327
+ assert r.status_code == 401
328
+
329
+ def test_any_username_accepted(self, secured_client):
330
+ r = secured_client.get("/dashboard", headers=self._basic_auth_headers(username="bob"))
331
+ assert r.status_code == 200
332
+
333
+ # -- Fenrir paths skip basic auth (use their own bearer auth) --
334
+
335
+ def test_fenrir_not_blocked_by_basic_auth(self, secured_client):
336
+ """Fenrir endpoints should not get a basic auth challenge — they use bearer."""
337
+ r = secured_client.get("/fenrir/", headers=auth_headers())
338
+ assert r.status_code == 200
339
+
340
+ def test_fenrir_without_any_auth_gets_bearer_401(self, secured_client):
341
+ """Fenrir without bearer token should return JSON 401, not basic auth challenge."""
342
+ r = secured_client.get("/fenrir/")
343
+ assert r.status_code == 401
344
+ data = r.get_json()
345
+ assert data is not None # JSON response, not basic auth HTML
346
+
347
+ # -- Debug mode --
348
+
349
+ def test_debug_skips_basic_auth(self, secured_app):
350
+ secured_app.debug = True
351
+ with secured_app.test_client() as c:
352
+ r = c.get("/dashboard")
353
+ assert r.status_code == 200
354
+
355
+ # -- No env var --
356
+
357
+ def test_no_env_var_returns_503(self, secured_client):
358
+ os.environ.pop("FENRIR_API_KEY", None)
359
+ r = secured_client.get("/dashboard")
360
+ assert r.status_code == 503
361
+
362
+ # -- skip_paths --
363
+
364
+ def test_skip_paths(self, tmp_path):
365
+ engine = create_engine("sqlite://")
366
+ SQLModel.metadata.create_all(engine)
367
+
368
+ app = Flask(__name__, root_path=str(tmp_path))
369
+ app.config["TESTING"] = True
370
+ app.register_blueprint(create_fenrir_bp(engine))
371
+ secure_app(app, skip_paths=["/health"])
372
+
373
+ @app.route("/health")
374
+ def health():
375
+ return "ok"
376
+
377
+ os.environ["FENRIR_API_KEY"] = API_KEY
378
+ with app.test_client() as c:
379
+ r = c.get("/health")
380
+ assert r.status_code == 200
381
+ os.environ.pop("FENRIR_API_KEY", None)
382
+
383
+ # -- api_key_auth --
384
+
385
+ def test_api_key_auth(self, tmp_path):
386
+ engine = create_engine("sqlite://")
387
+ SQLModel.metadata.create_all(engine)
388
+
389
+ MCP_SECRET = "mcp-secret-123"
390
+
391
+ app = Flask(__name__, root_path=str(tmp_path))
392
+ app.config["TESTING"] = True
393
+ app.register_blueprint(create_fenrir_bp(engine))
394
+ secure_app(app, api_key_auth={"header": "X-API-Key", "secret": MCP_SECRET})
395
+
396
+ @app.route("/api/stuff")
397
+ def stuff():
398
+ return "ok"
399
+
400
+ os.environ["FENRIR_API_KEY"] = API_KEY
401
+ with app.test_client() as c:
402
+ # Basic auth still works
403
+ r = c.get("/api/stuff", headers=self._basic_auth_headers())
404
+ assert r.status_code == 200
405
+ # API key header works with its own secret
406
+ r = c.get("/api/stuff", headers={"X-API-Key": MCP_SECRET})
407
+ assert r.status_code == 200
408
+ # FENRIR_API_KEY doesn't work as API key header
409
+ r = c.get("/api/stuff", headers={"X-API-Key": API_KEY})
410
+ assert r.status_code == 401
411
+ # Wrong secret rejected
412
+ r = c.get("/api/stuff", headers={"X-API-Key": "wrong"})
413
+ assert r.status_code == 401
414
+ os.environ.pop("FENRIR_API_KEY", None)
415
+