filingstudio-proxy 0.2.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,7 @@
1
+ node_modules/
2
+ dist/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ packages/proxy-python/dist/
7
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Filing Studio
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,114 @@
1
+ Metadata-Version: 2.5
2
+ Name: filingstudio-proxy
3
+ Version: 0.2.0
4
+ Summary: Server-side proxy for Filing Studio: hold the API key on your FastAPI server; the browser talks only to your route.
5
+ Project-URL: Homepage, https://filingstudio.com
6
+ Project-URL: Documentation, https://filingstudio.com/docs#sdk
7
+ Author-email: Filing Studio <support@filingstudio.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: citations,edgar,fastapi,filings,finance,provenance,proxy,sec
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Office/Business :: Financial
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: fastapi>=0.100
28
+ Requires-Dist: httpx>=0.24
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=7; extra == 'test'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # filingstudio-proxy
34
+
35
+ Server-side proxy for [Filing Studio](https://filingstudio.com) on FastAPI.
36
+ Your API key stays on your server; the browser talks only to your route.
37
+ This is the Python twin of `@filingstudio/proxy` (Next.js and Express), with
38
+ the same contract, so `@filingstudio/react` works unchanged in front of it.
39
+
40
+ ```bash
41
+ pip install filingstudio-proxy
42
+ ```
43
+
44
+ ```python
45
+ import os
46
+ from fastapi import FastAPI
47
+ from filingstudio_proxy import filing_studio_router
48
+
49
+ app = FastAPI()
50
+ app.include_router(
51
+ filing_studio_router(api_key=os.environ["FILING_STUDIO_API_KEY"]),
52
+ prefix="/api/filings",
53
+ )
54
+ ```
55
+
56
+ Then in the browser: `<ProvenanceProvider proxy="/api/filings">`.
57
+
58
+ ## Public demo? Cap each visitor
59
+
60
+ ```python
61
+ filing_studio_router(api_key=key, budget=40) # 40 metered requests per visitor per day
62
+ ```
63
+
64
+ Over budget the route answers `429` with an honest note the drawer shows
65
+ ("Daily demo limit reached… This says nothing about what the filings
66
+ contain"), never a fake "not found". Coverage checks are never metered, and
67
+ cached answers are served before the meter, so reopening a receipt is free.
68
+
69
+ Meter on your own login instead of the visitor IP:
70
+
71
+ ```python
72
+ filing_studio_router(
73
+ api_key=key,
74
+ budget=100,
75
+ visitor_key=lambda req: req.state.user_id,
76
+ limit_for=lambda req: req.state.daily_limit, # optional per-user quota
77
+ )
78
+ ```
79
+
80
+ ## What it guarantees
81
+
82
+ - Only `GET` and `POST`, only to an allowlist of `/v1` read paths. Dot
83
+ segments and backslashes are refused before allowlisting.
84
+ - Nothing from the browser request is forwarded except the `/v1` path, its
85
+ query string, and a POST body (capped at 64 KB).
86
+ - Successful `GET`s are cached in-process (one hour by default) and served
87
+ before the meter. Errors are never cached.
88
+ - The reply carries `X-FS-Demo-Remaining` when metered; the React SDK's
89
+ `onLimit` reads it.
90
+ - A missing key answers `503`; an unreachable upstream answers `502`. Neither
91
+ message can contain the key.
92
+
93
+ ## Options
94
+
95
+ | option | default | meaning |
96
+ |---|---|---|
97
+ | `api_key` | required | your `fsk_…` key, server-side only |
98
+ | `base_url` | `https://api.filingstudio.com` | upstream base |
99
+ | `allow` | search, trace, traces, verify, coverage, filings, tables | allowed `/v1` prefixes |
100
+ | `cache_ttl_s` | `3600` | GET cache TTL; `0` disables |
101
+ | `budget` | `None` | per-visitor daily cap (int) or a shared `Budget` |
102
+ | `visitor_key` | client IP, cookie fallback | who a request is charged to |
103
+ | `limit_for` | `None` | per-request quota override |
104
+ | `timeout_s` | `30` | upstream timeout |
105
+ | `transport` | `None` | an `httpx` transport, for tests |
106
+
107
+ ## Develop
108
+
109
+ ```bash
110
+ pip install -e .[test]
111
+ pytest
112
+ ```
113
+
114
+ MIT
@@ -0,0 +1,82 @@
1
+ # filingstudio-proxy
2
+
3
+ Server-side proxy for [Filing Studio](https://filingstudio.com) on FastAPI.
4
+ Your API key stays on your server; the browser talks only to your route.
5
+ This is the Python twin of `@filingstudio/proxy` (Next.js and Express), with
6
+ the same contract, so `@filingstudio/react` works unchanged in front of it.
7
+
8
+ ```bash
9
+ pip install filingstudio-proxy
10
+ ```
11
+
12
+ ```python
13
+ import os
14
+ from fastapi import FastAPI
15
+ from filingstudio_proxy import filing_studio_router
16
+
17
+ app = FastAPI()
18
+ app.include_router(
19
+ filing_studio_router(api_key=os.environ["FILING_STUDIO_API_KEY"]),
20
+ prefix="/api/filings",
21
+ )
22
+ ```
23
+
24
+ Then in the browser: `<ProvenanceProvider proxy="/api/filings">`.
25
+
26
+ ## Public demo? Cap each visitor
27
+
28
+ ```python
29
+ filing_studio_router(api_key=key, budget=40) # 40 metered requests per visitor per day
30
+ ```
31
+
32
+ Over budget the route answers `429` with an honest note the drawer shows
33
+ ("Daily demo limit reached… This says nothing about what the filings
34
+ contain"), never a fake "not found". Coverage checks are never metered, and
35
+ cached answers are served before the meter, so reopening a receipt is free.
36
+
37
+ Meter on your own login instead of the visitor IP:
38
+
39
+ ```python
40
+ filing_studio_router(
41
+ api_key=key,
42
+ budget=100,
43
+ visitor_key=lambda req: req.state.user_id,
44
+ limit_for=lambda req: req.state.daily_limit, # optional per-user quota
45
+ )
46
+ ```
47
+
48
+ ## What it guarantees
49
+
50
+ - Only `GET` and `POST`, only to an allowlist of `/v1` read paths. Dot
51
+ segments and backslashes are refused before allowlisting.
52
+ - Nothing from the browser request is forwarded except the `/v1` path, its
53
+ query string, and a POST body (capped at 64 KB).
54
+ - Successful `GET`s are cached in-process (one hour by default) and served
55
+ before the meter. Errors are never cached.
56
+ - The reply carries `X-FS-Demo-Remaining` when metered; the React SDK's
57
+ `onLimit` reads it.
58
+ - A missing key answers `503`; an unreachable upstream answers `502`. Neither
59
+ message can contain the key.
60
+
61
+ ## Options
62
+
63
+ | option | default | meaning |
64
+ |---|---|---|
65
+ | `api_key` | required | your `fsk_…` key, server-side only |
66
+ | `base_url` | `https://api.filingstudio.com` | upstream base |
67
+ | `allow` | search, trace, traces, verify, coverage, filings, tables | allowed `/v1` prefixes |
68
+ | `cache_ttl_s` | `3600` | GET cache TTL; `0` disables |
69
+ | `budget` | `None` | per-visitor daily cap (int) or a shared `Budget` |
70
+ | `visitor_key` | client IP, cookie fallback | who a request is charged to |
71
+ | `limit_for` | `None` | per-request quota override |
72
+ | `timeout_s` | `30` | upstream timeout |
73
+ | `transport` | `None` | an `httpx` transport, for tests |
74
+
75
+ ## Develop
76
+
77
+ ```bash
78
+ pip install -e .[test]
79
+ pytest
80
+ ```
81
+
82
+ MIT
@@ -0,0 +1,22 @@
1
+ """Server-side proxy for Filing Studio. Hold the API key on your FastAPI
2
+ server; the browser talks only to your route."""
3
+
4
+ from .router import (
5
+ DEFAULT_ALLOW,
6
+ MAX_BODY_BYTES,
7
+ REMAINING_HEADER,
8
+ Budget,
9
+ filing_studio_router,
10
+ v1_tail,
11
+ )
12
+
13
+ __version__ = "0.2.0"
14
+ __all__ = [
15
+ "Budget",
16
+ "DEFAULT_ALLOW",
17
+ "MAX_BODY_BYTES",
18
+ "REMAINING_HEADER",
19
+ "__version__",
20
+ "filing_studio_router",
21
+ "v1_tail",
22
+ ]
@@ -0,0 +1,313 @@
1
+ """
2
+ The key-holding proxy for FastAPI, behaviour for behaviour with the
3
+ TypeScript @filingstudio/proxy. Mount it under the prefix your
4
+ <ProvenanceProvider proxy="..."> points at; the browser only ever talks to
5
+ your server, and the API key never leaves it.
6
+
7
+ from filingstudio_proxy import filing_studio_router
8
+ app.include_router(
9
+ filing_studio_router(api_key=os.environ["FILING_STUDIO_API_KEY"]),
10
+ prefix="/api/filings",
11
+ )
12
+
13
+ Rules, all enforced here:
14
+ * only GET and POST are forwarded, only to an allowlist of /v1 prefixes;
15
+ dot segments and backslashes in the path are refused before allowlisting;
16
+ * nothing from the browser's request reaches upstream except the /v1 path,
17
+ its query string, and a POST body (capped at 64 KB);
18
+ * successful GETs are cached in-process and served BEFORE the meter, so
19
+ reopening a receipt costs no quota and is never refused once quota is
20
+ spent; errors are never cached;
21
+ * an optional per-visitor daily budget (client IP first, cookie fallback)
22
+ answers 429 with an honest indexState note, never a fake "not found";
23
+ coverage is exempt because it decides whether to offer View-source at all;
24
+ * a missing key answers 503 and an unreachable upstream answers 502, and
25
+ neither message can ever contain the key.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import secrets
32
+ import threading
33
+ import time
34
+ from datetime import date
35
+ from typing import Callable, Iterable, Optional, Tuple, Union
36
+ from urllib.parse import quote, unquote
37
+
38
+ import httpx
39
+ from fastapi import APIRouter, Request, Response
40
+ from fastapi.responses import JSONResponse
41
+
42
+ DEFAULT_BASE = "https://api.filingstudio.com"
43
+ DEFAULT_ALLOW: Tuple[str, ...] = (
44
+ "v1/search",
45
+ "v1/trace",
46
+ "v1/traces",
47
+ "v1/verify",
48
+ "v1/coverage",
49
+ "v1/filings",
50
+ "v1/tables",
51
+ )
52
+ METER_EXEMPT: Tuple[str, ...] = ("v1/coverage",)
53
+ MAX_BODY_BYTES = 64 * 1024
54
+ MAX_ENTRIES = 5000
55
+ REMAINING_HEADER = "X-FS-Demo-Remaining"
56
+ VISITOR_COOKIE = "fs_visitor"
57
+ LIMIT_NOTE = (
58
+ "Daily demo limit reached — try again tomorrow. "
59
+ "This says nothing about what the filings contain."
60
+ )
61
+
62
+
63
+ def v1_tail(path: str) -> Optional[str]:
64
+ """The `v1/…` tail of a request path, decoded, or None when it is not a
65
+ /v1 path or carries anything that could re-route the upstream request."""
66
+ at = path.find("/v1/")
67
+ if at < 0:
68
+ return None
69
+ tail = unquote(path[at + 1 :])
70
+ if "\\" in tail:
71
+ return None
72
+ if any(seg in (".", "..") for seg in tail.split("/")):
73
+ return None
74
+ return tail
75
+
76
+
77
+ def _matches(tail: str, prefixes: Iterable[str]) -> bool:
78
+ return any(tail == p or tail.startswith(p + "/") for p in prefixes)
79
+
80
+
81
+ class Budget:
82
+ """A per-visitor daily budget. `spend(key, limit)` charges one request and
83
+ returns (allowed, remaining_today). Bounded: oldest keys are dropped past
84
+ MAX_ENTRIES."""
85
+
86
+ def __init__(self, per_visitor_daily: int) -> None:
87
+ self.per_visitor_daily = int(per_visitor_daily)
88
+ self._store: "dict[str, tuple[str, int]]" = {}
89
+ self._lock = threading.Lock()
90
+
91
+ def spend(self, key: str, limit: Optional[int] = None) -> Tuple[bool, int]:
92
+ lim = self.per_visitor_daily if limit is None else int(limit)
93
+ today = date.today().isoformat()
94
+ with self._lock:
95
+ day, used = self._store.get(key, (today, 0))
96
+ if day != today:
97
+ used = 0
98
+ if used >= lim:
99
+ self._store[key] = (today, used)
100
+ return False, 0
101
+ used += 1
102
+ self._store[key] = (today, used)
103
+ while len(self._store) > MAX_ENTRIES:
104
+ self._store.pop(next(iter(self._store)))
105
+ return True, lim - used
106
+
107
+
108
+ class _Cache:
109
+ def __init__(self, ttl_s: float) -> None:
110
+ self.ttl = float(ttl_s)
111
+ self._store: "dict[str, tuple[float, int, bytes]]" = {}
112
+ self._lock = threading.Lock()
113
+
114
+ def get(self, key: str) -> Optional[Tuple[int, bytes]]:
115
+ if self.ttl <= 0:
116
+ return None
117
+ with self._lock:
118
+ hit = self._store.get(key)
119
+ if not hit:
120
+ return None
121
+ at, status, body = hit
122
+ if time.time() - at > self.ttl:
123
+ self._store.pop(key, None)
124
+ return None
125
+ return status, body
126
+
127
+ def put(self, key: str, status: int, body: bytes) -> None:
128
+ if self.ttl <= 0:
129
+ return
130
+ with self._lock:
131
+ self._store[key] = (time.time(), status, body)
132
+ while len(self._store) > MAX_ENTRIES:
133
+ self._store.pop(next(iter(self._store)))
134
+
135
+
136
+ def _client_ip(request: Request) -> str:
137
+ xff = request.headers.get("x-forwarded-for") or ""
138
+ first = xff.split(",")[0].strip()
139
+ if first:
140
+ return first
141
+ real = (request.headers.get("x-real-ip") or "").strip()
142
+ if real:
143
+ return real
144
+ return (request.client.host if request.client else "") or ""
145
+
146
+
147
+ def _default_visitor(request: Request, response: Response) -> str:
148
+ """The IP is the meter. The cookie only stands in when no IP is known,
149
+ so a visitor cannot reset their budget by clearing it."""
150
+ ip = _client_ip(request)
151
+ if ip:
152
+ return ip
153
+ cookie = request.cookies.get(VISITOR_COOKIE)
154
+ if cookie:
155
+ return cookie
156
+ cookie = secrets.token_urlsafe(16)
157
+ secure = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
158
+ response.set_cookie(
159
+ VISITOR_COOKIE, cookie, max_age=365 * 24 * 3600,
160
+ httponly=True, samesite="lax", secure=secure,
161
+ )
162
+ return cookie
163
+
164
+
165
+ def _json_bytes(obj: dict) -> bytes:
166
+ return json.dumps(obj).encode()
167
+
168
+
169
+ def filing_studio_router(
170
+ api_key: Optional[str],
171
+ *,
172
+ base_url: Optional[str] = None,
173
+ allow: Iterable[str] = DEFAULT_ALLOW,
174
+ cache_ttl_s: float = 3600.0,
175
+ budget: Union[Budget, int, None] = None,
176
+ visitor_key: Optional[Callable[[Request], str]] = None,
177
+ limit_for: Optional[Callable[[Request], Optional[int]]] = None,
178
+ timeout_s: float = 30.0,
179
+ transport: Optional[httpx.AsyncBaseTransport] = None,
180
+ ) -> APIRouter:
181
+ """Build the router.
182
+
183
+ api_key your Filing Studio key (fsk_…). Server-side only.
184
+ base_url upstream base; default https://api.filingstudio.com.
185
+ allow allowed /v1 prefixes; defaults cover everything the SDK calls.
186
+ cache_ttl_s GET cache TTL in seconds; 0 disables.
187
+ budget per-visitor daily cap: an int, or a Budget to share one meter.
188
+ visitor_key who a request is charged to (default: client IP, cookie
189
+ fallback). Return your own login id to meter per user.
190
+ limit_for a per-request quota override (e.g. a per-client limit).
191
+ timeout_s upstream timeout.
192
+ transport an httpx transport, for tests (httpx.MockTransport).
193
+ """
194
+ key = (api_key or "").strip()
195
+ base = (base_url or DEFAULT_BASE).rstrip("/")
196
+ allowed = tuple(allow)
197
+ meter = Budget(budget) if isinstance(budget, int) else budget
198
+ cache = _Cache(cache_ttl_s)
199
+ router = APIRouter()
200
+
201
+ def reply(status: int, obj: dict, headers: Optional[dict] = None) -> JSONResponse:
202
+ return JSONResponse(obj, status_code=status, headers=headers or {})
203
+
204
+ async def handle(request: Request, path: str) -> Response:
205
+ method = request.method.upper()
206
+ if method not in ("GET", "POST"):
207
+ return reply(405, {"error": f"Method not allowed: {method}"}, {"Allow": "GET, POST"})
208
+ tail = v1_tail("/" + path)
209
+ if tail is None:
210
+ return reply(404, {"error": "Not a /v1 path"})
211
+ if not _matches(tail, allowed):
212
+ return reply(403, {"error": f"Path not allowed through this proxy: {tail}"})
213
+ if not key:
214
+ return reply(
215
+ 503,
216
+ {
217
+ "error": {
218
+ "code": "not_configured",
219
+ "message": "Filing evidence is not configured on this server.",
220
+ },
221
+ "indexState": {
222
+ "coverage": "unavailable",
223
+ "note": "The evidence service is not configured. "
224
+ "This says nothing about what the filings contain.",
225
+ },
226
+ },
227
+ )
228
+
229
+ body: Optional[bytes] = None
230
+ if method == "POST":
231
+ declared = int(request.headers.get("content-length") or 0)
232
+ if declared > MAX_BODY_BYTES:
233
+ return reply(413, {"error": "Request body too large"})
234
+ body = await request.body()
235
+ if len(body) > MAX_BODY_BYTES:
236
+ return reply(413, {"error": "Request body too large"})
237
+
238
+ query = request.url.query
239
+ upstream = f"{base}/{quote(tail, safe='/')}" + (f"?{query}" if query else "")
240
+ cache_key = upstream if method == "GET" else None
241
+
242
+ # A cached answer is free: serve it before the meter runs, so reopening
243
+ # the same receipt never spends (or is refused for lack of) quota.
244
+ if cache_key:
245
+ hit = cache.get(cache_key)
246
+ if hit:
247
+ return Response(content=hit[1], status_code=hit[0], media_type="application/json")
248
+
249
+ headers: "dict[str, str]" = {}
250
+ if meter is not None and not _matches(tail, METER_EXEMPT):
251
+ cookie_carrier = Response()
252
+ who = visitor_key(request) if visitor_key else _default_visitor(request, cookie_carrier)
253
+ limit = limit_for(request) if limit_for else None
254
+ ok, remaining = meter.spend(f"{who}|{date.today().isoformat()}", limit)
255
+ headers[REMAINING_HEADER] = str(remaining)
256
+ set_cookie = cookie_carrier.headers.get("set-cookie")
257
+ if set_cookie:
258
+ headers["Set-Cookie"] = set_cookie
259
+ if not ok:
260
+ return reply(
261
+ 429,
262
+ {
263
+ "error": "Daily demo limit reached.",
264
+ "indexState": {"coverage": "unavailable", "note": LIMIT_NOTE},
265
+ },
266
+ headers,
267
+ )
268
+
269
+ up_headers = {"X-API-Key": key}
270
+ if method == "POST":
271
+ up_headers["Content-Type"] = "application/json"
272
+ try:
273
+ async with httpx.AsyncClient(timeout=timeout_s, transport=transport) as client:
274
+ res = await client.request(method, upstream, headers=up_headers, content=body)
275
+ except httpx.HTTPError as exc:
276
+ # Only the exception's class name: httpx messages can echo the URL.
277
+ return reply(
278
+ 502,
279
+ {
280
+ "error": {
281
+ "code": "upstream_unreachable",
282
+ "message": f"Filing Studio could not be reached ({type(exc).__name__}).",
283
+ },
284
+ "indexState": {
285
+ "coverage": "unavailable",
286
+ "note": "The evidence service could not be reached. "
287
+ "This says nothing about what the filings contain.",
288
+ },
289
+ },
290
+ headers,
291
+ )
292
+
293
+ content = res.content
294
+ try:
295
+ json.loads(content)
296
+ except ValueError:
297
+ content = _json_bytes({"error": "Upstream returned a non-JSON response"})
298
+ if cache_key and res.is_success:
299
+ cache.put(cache_key, res.status_code, content)
300
+ return Response(
301
+ content=content, status_code=res.status_code,
302
+ media_type="application/json", headers=headers,
303
+ )
304
+
305
+ @router.api_route(
306
+ "/v1/{path:path}",
307
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
308
+ include_in_schema=False,
309
+ )
310
+ async def v1(path: str, request: Request) -> Response:
311
+ return await handle(request, "v1/" + path)
312
+
313
+ return router
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "filingstudio-proxy"
7
+ version = "0.2.0"
8
+ description = "Server-side proxy for Filing Studio: hold the API key on your FastAPI server; the browser talks only to your route."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Filing Studio", email = "support@filingstudio.com" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["fastapi>=0.100", "httpx>=0.24"]
14
+ keywords = ["sec", "filings", "citations", "provenance", "fastapi", "proxy", "finance", "edgar"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: FastAPI",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Office/Business :: Financial",
29
+ "Topic :: Software Development :: Libraries",
30
+ "Typing :: Typed",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://filingstudio.com"
35
+ Documentation = "https://filingstudio.com/docs#sdk"
36
+
37
+ [project.optional-dependencies]
38
+ test = ["pytest>=7"]
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["filingstudio_proxy"]
42
+
43
+ [tool.hatch.build.targets.sdist]
44
+ include = ["filingstudio_proxy", "tests", "README.md", "LICENSE", "pyproject.toml"]
45
+
46
+ [tool.pytest.ini_options]
47
+ pythonpath = ["."]
48
+ testpaths = ["tests"]
@@ -0,0 +1,180 @@
1
+ import httpx
2
+ import pytest
3
+ from fastapi import FastAPI
4
+ from fastapi.testclient import TestClient
5
+
6
+ from filingstudio_proxy import Budget, filing_studio_router, v1_tail, REMAINING_HEADER
7
+
8
+
9
+ def ok(req: httpx.Request) -> httpx.Response:
10
+ return httpx.Response(200, json={"data": {"ok": True, "path": req.url.path, "q": req.url.query.decode()}})
11
+
12
+
13
+ def make(handler=ok, **kw):
14
+ seen = []
15
+
16
+ def h(req: httpx.Request) -> httpx.Response:
17
+ seen.append(req)
18
+ return handler(req)
19
+
20
+ app = FastAPI()
21
+ app.include_router(
22
+ filing_studio_router(transport=httpx.MockTransport(h), **kw),
23
+ prefix="/api/filings",
24
+ )
25
+ return TestClient(app), seen
26
+
27
+
28
+ IP = {"x-forwarded-for": "203.0.113.7"}
29
+
30
+
31
+ def test_v1_tail_decodes_before_checking_dot_segments():
32
+ assert v1_tail("/api/filings/v1/trace/abc") == "v1/trace/abc"
33
+ assert v1_tail("/api/filings/v1/trace/%2e%2e/x") is None
34
+ assert v1_tail("/api/filings/v1/a\\b") is None
35
+ assert v1_tail("/api/filings/nope") is None
36
+
37
+
38
+ def test_rejects_traversal_and_non_allowlisted_paths_without_calling_upstream():
39
+ c, seen = make(api_key="k")
40
+ assert c.get("/api/filings/v1/search/%2e%2e/%2e%2e/admin").status_code == 404
41
+ assert c.get("/api/filings/v1/admin").status_code == 403
42
+ assert c.get("/api/filings/v1/searchX").status_code == 403
43
+ assert seen == []
44
+
45
+
46
+ def test_missing_key_answers_503_with_an_honest_index_state():
47
+ c, seen = make(api_key=None)
48
+ r = c.get("/api/filings/v1/trace/T")
49
+ assert r.status_code == 503
50
+ assert r.json()["indexState"]["coverage"] == "unavailable"
51
+ assert seen == []
52
+
53
+
54
+ def test_forwards_key_and_path_only_never_browser_headers():
55
+ c, seen = make(api_key="secret")
56
+ r = c.get(
57
+ "/api/filings/v1/search?ticker=NVDA&q=revenue",
58
+ headers={"cookie": "session=abc", "authorization": "Bearer x", **IP},
59
+ )
60
+ assert r.status_code == 200
61
+ up = seen[0]
62
+ assert up.headers["x-api-key"] == "secret"
63
+ assert "cookie" not in up.headers
64
+ assert "authorization" not in up.headers
65
+ assert str(up.url) == "https://api.filingstudio.com/v1/search?ticker=NVDA&q=revenue"
66
+
67
+
68
+ def test_coverage_is_never_metered():
69
+ c, seen = make(api_key="k", budget=1)
70
+ assert c.get("/api/filings/v1/trace/T1", headers=IP).status_code == 200
71
+ for i in range(3):
72
+ r = c.get(f"/api/filings/v1/coverage?ticker=NVDA&i={i}", headers=IP)
73
+ assert r.status_code == 200
74
+ assert REMAINING_HEADER not in r.headers
75
+ assert len(seen) == 4
76
+
77
+
78
+ def test_cache_is_served_before_the_meter_and_never_charged():
79
+ c, seen = make(api_key="k", budget=1)
80
+ first = c.get("/api/filings/v1/trace/T1?include=context", headers=IP)
81
+ assert first.status_code == 200
82
+ assert first.headers[REMAINING_HEADER] == "0"
83
+
84
+ again = c.get("/api/filings/v1/trace/T1?include=context", headers=IP)
85
+ assert again.status_code == 200
86
+ assert again.json() == first.json()
87
+ assert len(seen) == 1
88
+
89
+ other = c.get("/api/filings/v1/trace/T2?include=context", headers=IP)
90
+ assert other.status_code == 429
91
+ body = other.json()
92
+ assert body["indexState"]["coverage"] == "unavailable"
93
+ assert "limit" in body["indexState"]["note"].lower()
94
+ assert other.headers[REMAINING_HEADER] == "0"
95
+ assert len(seen) == 1
96
+
97
+
98
+ def test_meters_on_ip_so_a_new_cookie_does_not_reset_the_budget():
99
+ c, _ = make(api_key="k", budget=1)
100
+ assert c.get("/api/filings/v1/trace/A", headers={**IP, "cookie": "fs_visitor=one"}).status_code == 200
101
+ assert c.get("/api/filings/v1/trace/B", headers={**IP, "cookie": "fs_visitor=two"}).status_code == 429
102
+ assert c.get("/api/filings/v1/trace/C", headers={"x-forwarded-for": "198.51.100.9"}).status_code == 200
103
+
104
+
105
+ def test_visitor_key_and_limit_for_hooks_meter_on_the_hosts_login():
106
+ def who(request):
107
+ return request.headers.get("x-user", "anon")
108
+
109
+ def limit(request):
110
+ return 2 if request.headers.get("x-user") == "pro" else 1
111
+
112
+ c, _ = make(api_key="k", budget=Budget(99), visitor_key=who, limit_for=limit)
113
+ # distinct paths per user: a cached answer is served before the meter
114
+ assert c.get("/api/filings/v1/trace/F1", headers={"x-user": "free"}).status_code == 200
115
+ assert c.get("/api/filings/v1/trace/F2", headers={"x-user": "free"}).status_code == 429
116
+ assert c.get("/api/filings/v1/trace/P1", headers={"x-user": "pro"}).status_code == 200
117
+ assert c.get("/api/filings/v1/trace/P2", headers={"x-user": "pro"}).status_code == 200
118
+ assert c.get("/api/filings/v1/trace/P3", headers={"x-user": "pro"}).status_code == 429
119
+ # and the free user can still reopen what is already cached
120
+ assert c.get("/api/filings/v1/trace/F1", headers={"x-user": "free"}).status_code == 200
121
+
122
+
123
+ def test_post_body_cap_and_method_refusal():
124
+ c, seen = make(api_key="k")
125
+ big = "x" * (64 * 1024 + 1)
126
+ assert c.post("/api/filings/v1/verify", content=big).status_code == 413
127
+ r = c.put("/api/filings/v1/verify", content="{}")
128
+ assert r.status_code == 405
129
+ assert r.headers["allow"] == "GET, POST"
130
+ assert seen == []
131
+
132
+
133
+ def test_post_forwards_json_body_with_content_type():
134
+ c, seen = make(api_key="k")
135
+ r = c.post("/api/filings/v1/verify", json={"ticker": "NVDA", "metric": "Revenue", "value": 130497})
136
+ assert r.status_code == 200
137
+ assert seen[0].method == "POST"
138
+ assert seen[0].headers["content-type"] == "application/json"
139
+ assert b'"ticker": "NVDA"' in seen[0].content or b'"ticker":"NVDA"' in seen[0].content
140
+
141
+
142
+ def test_upstream_error_is_502_and_never_leaks_the_key():
143
+ def boom(req):
144
+ raise httpx.ConnectError("no route to host")
145
+
146
+ c, _ = make(boom, api_key="secret-key-value")
147
+ r = c.get("/api/filings/v1/trace/T")
148
+ assert r.status_code == 502
149
+ assert "secret-key-value" not in r.text
150
+ assert r.json()["indexState"]["coverage"] == "unavailable"
151
+
152
+
153
+ def test_non_json_upstream_becomes_a_json_error():
154
+ c, _ = make(lambda req: httpx.Response(200, text="<html>nope</html>"), api_key="k")
155
+ r = c.get("/api/filings/v1/trace/T")
156
+ assert r.status_code == 200
157
+ assert r.json()["error"].startswith("Upstream returned a non-JSON")
158
+
159
+
160
+ def test_errors_are_never_cached():
161
+ n = {"i": 0}
162
+
163
+ def flaky(req):
164
+ n["i"] += 1
165
+ return httpx.Response(500, json={"error": "boom"}) if n["i"] == 1 else ok(req)
166
+
167
+ c, _ = make(flaky, api_key="k")
168
+ assert c.get("/api/filings/v1/trace/T").status_code == 500
169
+ assert c.get("/api/filings/v1/trace/T").status_code == 200
170
+ assert n["i"] == 2
171
+
172
+
173
+ def test_budget_resets_per_key_and_reports_remaining():
174
+ b = Budget(3)
175
+ assert b.spend("a") == (True, 2)
176
+ assert b.spend("a") == (True, 1)
177
+ assert b.spend("a") == (True, 0)
178
+ assert b.spend("a") == (False, 0)
179
+ assert b.spend("b") == (True, 2)
180
+ assert b.spend("a", limit=5) == (True, 1)