filingstudio-proxy 0.2.0__py3-none-any.whl

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,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,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,6 @@
1
+ filingstudio_proxy/__init__.py,sha256=tZBkz0nFK8HkmWx_4qaOn_pUDtBYoFZY3ARw-R901m4,439
2
+ filingstudio_proxy/router.py,sha256=17nDT1pKIU6gEMmXT9W8fExjzWX_SWXNLPkV5eXdRH8,11949
3
+ filingstudio_proxy-0.2.0.dist-info/METADATA,sha256=M6anuvXpB198ULBkpyI9kmrJnHxAJZsCU3W2qs1OjEA,4019
4
+ filingstudio_proxy-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ filingstudio_proxy-0.2.0.dist-info/licenses/LICENSE,sha256=0N9MXUpOueb0VCrG2YJoMksp8VFEVrsgbeIsEKm00RU,1070
6
+ filingstudio_proxy-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.