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,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.
|