sdmxlib-rest 0.1.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.
- sdmxlib_rest/__init__.py +19 -0
- sdmxlib_rest/app.py +388 -0
- sdmxlib_rest/availability_cache.py +80 -0
- sdmxlib_rest/data/__init__.py +94 -0
- sdmxlib_rest/data/encoding.py +153 -0
- sdmxlib_rest/data/paging.py +72 -0
- sdmxlib_rest/data/params.py +284 -0
- sdmxlib_rest/data/projection.py +240 -0
- sdmxlib_rest/data/sql.py +84 -0
- sdmxlib_rest/filter_shaper.py +78 -0
- sdmxlib_rest/localization.py +167 -0
- sdmxlib_rest/logging.py +28 -0
- sdmxlib_rest/message_annotator.py +68 -0
- sdmxlib_rest/metadata_store.py +81 -0
- sdmxlib_rest/middleware/__init__.py +6 -0
- sdmxlib_rest/middleware/compression.py +149 -0
- sdmxlib_rest/middleware/etag.py +175 -0
- sdmxlib_rest/middleware/head_method.py +76 -0
- sdmxlib_rest/middleware/lang_override.py +75 -0
- sdmxlib_rest/middleware/openapi_extensions.py +106 -0
- sdmxlib_rest/middleware/perf_log.py +140 -0
- sdmxlib_rest/middleware/query_param_guard.py +126 -0
- sdmxlib_rest/middleware/version_token.py +76 -0
- sdmxlib_rest/negotiation.py +146 -0
- sdmxlib_rest/provider.py +73 -0
- sdmxlib_rest/responses/__init__.py +6 -0
- sdmxlib_rest/responses/asof.py +49 -0
- sdmxlib_rest/responses/empty.py +99 -0
- sdmxlib_rest/responses/urn_injection.py +157 -0
- sdmxlib_rest/routes/__init__.py +7 -0
- sdmxlib_rest/routes/availability.py +793 -0
- sdmxlib_rest/routes/metadata.py +428 -0
- sdmxlib_rest/routes/structure.py +1205 -0
- sdmxlib_rest/runtime.py +176 -0
- sdmxlib_rest/splice.py +146 -0
- sdmxlib_rest/structure_cache.py +75 -0
- sdmxlib_rest-0.1.0.dist-info/METADATA +114 -0
- sdmxlib_rest-0.1.0.dist-info/RECORD +40 -0
- sdmxlib_rest-0.1.0.dist-info/WHEEL +4 -0
- sdmxlib_rest-0.1.0.dist-info/licenses/LICENSE +185 -0
sdmxlib_rest/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""sdmxlib-rest — generic SDMX-REST HTTP surface over a sdmxlib ``FederatedCatalog``.
|
|
2
|
+
|
|
3
|
+
This package is being extracted in place from ``cpt_sdmx_api`` (see
|
|
4
|
+
``~/.claude/plans/so-as-this-project-misty-pond.md``). It owns the generic
|
|
5
|
+
HTTP shape of SDMX-REST — URLs, content negotiation, streaming, and
|
|
6
|
+
formatting delegation to sdmxlib — over an abstract catalog. Consumers
|
|
7
|
+
supply how the catalog is opened, what gets spliced into responses, and any
|
|
8
|
+
catalog-specific speedups.
|
|
9
|
+
|
|
10
|
+
During the in-place phase it ships as a second module inside the
|
|
11
|
+
``cpt-sdmx-api`` distribution; Phase 5 moves it to its own sibling repo
|
|
12
|
+
(``gitlab.com/pinax-suite/sdmxlib-rest``). It MUST NOT import ``cpt_sdmx_api``
|
|
13
|
+
or ``pinax`` — that separation is the whole point of the boundary, and
|
|
14
|
+
``tests/test_library_import_boundary.py`` fails the build if it slips.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
__all__: list[str] = []
|
sdmxlib_rest/app.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""The generic SDMX-REST application factory.
|
|
2
|
+
|
|
3
|
+
What a consumer actually has to get right to stand up an SDMX-REST service is
|
|
4
|
+
not the routes — those are already library-owned — but the *assembly*: the
|
|
5
|
+
middleware stack has an ordering invariant that is easy to violate and whose
|
|
6
|
+
violations are subtle (an ETag computed over compressed bytes still looks like
|
|
7
|
+
an ETag), the runtime has to be shared rather than rebuilt across mounted
|
|
8
|
+
surfaces or each one opens its own catalog, and the multi-spec docs page has to
|
|
9
|
+
be hand-rolled because FastAPI's helper can't emit one.
|
|
10
|
+
|
|
11
|
+
So this module owns the assembly and leaves every catalog-shaped decision to
|
|
12
|
+
the caller: which routers to include and in what order, what extra middleware
|
|
13
|
+
to layer, which sub-apps to mount, and what to warm at startup.
|
|
14
|
+
|
|
15
|
+
See :func:`create_app`. The ordering invariant is documented at its call to
|
|
16
|
+
``_install_middleware`` and is the single most important thing here — #22
|
|
17
|
+
tracks giving it regression coverage.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
|
|
23
|
+
from contextlib import asynccontextmanager
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from attrs import field, frozen
|
|
27
|
+
from fastapi import APIRouter, Depends, FastAPI
|
|
28
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
29
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
30
|
+
from loguru import logger
|
|
31
|
+
|
|
32
|
+
from sdmxlib_rest.middleware.compression import SelectiveGZipMiddleware, install_isal_zip_backend
|
|
33
|
+
from sdmxlib_rest.middleware.etag import ConditionalGetMiddleware
|
|
34
|
+
from sdmxlib_rest.middleware.head_method import HeadMethodMiddleware
|
|
35
|
+
from sdmxlib_rest.middleware.lang_override import LangOverrideMiddleware
|
|
36
|
+
from sdmxlib_rest.middleware.openapi_extensions import apply_vendor_markers
|
|
37
|
+
from sdmxlib_rest.middleware.perf_log import PerfLogMiddleware
|
|
38
|
+
from sdmxlib_rest.middleware.query_param_guard import reject_unknown_query_params
|
|
39
|
+
from sdmxlib_rest.middleware.version_token import SdmxVersionAdvertiseMiddleware
|
|
40
|
+
from sdmxlib_rest.routes import availability, metadata, structure
|
|
41
|
+
from sdmxlib_rest.runtime import CatalogRuntime, attach_runtime, share_runtime
|
|
42
|
+
|
|
43
|
+
# CORS response headers a browser-based SDMX client actually reads from script.
|
|
44
|
+
# Anything not listed here is hidden from JS on a cross-origin fetch.
|
|
45
|
+
_DEFAULT_EXPOSE_HEADERS: tuple[str, ...] = (
|
|
46
|
+
"ETag",
|
|
47
|
+
"Last-Modified",
|
|
48
|
+
"Content-Disposition",
|
|
49
|
+
"Vary",
|
|
50
|
+
"Content-Encoding",
|
|
51
|
+
# Range pagination — clients read `Content-Range` off the response;
|
|
52
|
+
# `Accept-Ranges` rides along for feature detection.
|
|
53
|
+
"Content-Range",
|
|
54
|
+
"Accept-Ranges",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# POST is here for the `/body` form-fallback routes: clients POST an
|
|
58
|
+
# `application/x-www-form-urlencoded` body carrying the SDMX key when the GET
|
|
59
|
+
# URL would exceed an ingress limit. Without it the browser preflight rejects
|
|
60
|
+
# the fallback.
|
|
61
|
+
_DEFAULT_ALLOW_METHODS: tuple[str, ...] = ("GET", "HEAD", "OPTIONS", "POST")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@frozen
|
|
65
|
+
class RestSettings:
|
|
66
|
+
"""Configuration the generic app assembly needs — nothing catalog-specific.
|
|
67
|
+
|
|
68
|
+
A consumer's own settings object will carry far more (bundle paths, log
|
|
69
|
+
destinations, feature flags); map the relevant slice onto this.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
title: str
|
|
73
|
+
version: str
|
|
74
|
+
description: str = ""
|
|
75
|
+
cache_control: str = "public, max-age=300, stale-while-revalidate=3600"
|
|
76
|
+
cors_allowed_origins: tuple[str, ...] = ("*",)
|
|
77
|
+
cors_max_age: int = 3600
|
|
78
|
+
# Level 1: SDMX-CSV is highly compressible (long format with repeated
|
|
79
|
+
# dimension values — ~37x measured on census data), and a low level keeps
|
|
80
|
+
# the CPU cost from eating the streaming throughput.
|
|
81
|
+
gzip_minimum_size: int = 1024
|
|
82
|
+
gzip_compress_level: int = 1
|
|
83
|
+
canonical_spec_name: str = "SDMX-REST 2.2.2 (canonical)"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@frozen
|
|
87
|
+
class Mount:
|
|
88
|
+
"""A sub-app mounted under the main app.
|
|
89
|
+
|
|
90
|
+
Mounted as a sub-app rather than a prefixed router so it publishes its own
|
|
91
|
+
``/openapi.json``, which is what lets the docs page offer a spec dropdown.
|
|
92
|
+
The mount shares the parent's runtime — one catalog open serves every
|
|
93
|
+
surface — and receives a copy of the parent's ``state``, because request
|
|
94
|
+
dependencies resolve against ``request.app``, which is the *sub*-app for a
|
|
95
|
+
request that lands inside a mount.
|
|
96
|
+
|
|
97
|
+
``spec_name`` labels it in the docs dropdown; ``None`` keeps it out.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
path: str
|
|
101
|
+
app: FastAPI
|
|
102
|
+
spec_name: str | None = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@frozen
|
|
106
|
+
class MiddlewareSpec:
|
|
107
|
+
"""A middleware class plus its keyword arguments, applied at the outer slot."""
|
|
108
|
+
|
|
109
|
+
cls: type
|
|
110
|
+
options: Mapping[str, Any] = field(factory=dict)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def library_routers() -> tuple[APIRouter, ...]:
|
|
114
|
+
"""The routers this library owns: structure, metadata, availability.
|
|
115
|
+
|
|
116
|
+
Returned in a sensible default order. A consumer that interleaves its own
|
|
117
|
+
routers should pass an explicit list to :func:`create_app` instead — route
|
|
118
|
+
inclusion order is preserved verbatim and determines OpenAPI path order.
|
|
119
|
+
"""
|
|
120
|
+
return (structure.router, metadata.router, availability.router)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def create_app(
|
|
124
|
+
runtime: CatalogRuntime,
|
|
125
|
+
settings: RestSettings,
|
|
126
|
+
*,
|
|
127
|
+
routers: Sequence[APIRouter] = (),
|
|
128
|
+
outer_middleware: Sequence[MiddlewareSpec] = (),
|
|
129
|
+
mounts: Sequence[Mount] = (),
|
|
130
|
+
state: Mapping[str, Any] | None = None,
|
|
131
|
+
perf_sink: Callable[..., None] | None = None,
|
|
132
|
+
on_startup: Callable[[FastAPI], Awaitable[None]] | None = None,
|
|
133
|
+
on_shutdown: Callable[[], None] | None = None,
|
|
134
|
+
root_payload: Mapping[str, Any] | None = None,
|
|
135
|
+
) -> FastAPI:
|
|
136
|
+
"""Assemble a FastAPI app serving SDMX-REST over ``runtime``'s catalog.
|
|
137
|
+
|
|
138
|
+
The catalog is opened once, on startup, and shared with every mount. The
|
|
139
|
+
returned app is ordinary FastAPI — a consumer is free to keep mounting
|
|
140
|
+
static files or including further routers afterwards.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
runtime: Carries the provider and every seam (splices, caches, filter
|
|
144
|
+
shaper, annotator). Install those before calling; they are read per
|
|
145
|
+
request, so later mutation works but is rarely what you want.
|
|
146
|
+
settings: The generic slice of configuration — see :class:`RestSettings`.
|
|
147
|
+
routers: Routers to include, **in order**. Order is preserved because it
|
|
148
|
+
determines the order paths appear in the OpenAPI document. Use
|
|
149
|
+
:func:`library_routers` for the standard trio, or interleave your own.
|
|
150
|
+
outer_middleware: Extra middleware layered just inside CORS — i.e.
|
|
151
|
+
outside everything else, so it observes the client's original
|
|
152
|
+
request and the fully-encoded response. Logging taps belong here.
|
|
153
|
+
mounts: Sub-apps to mount; each shares this app's runtime and state.
|
|
154
|
+
state: Attributes to stamp on ``app.state`` and mirror onto every mount.
|
|
155
|
+
perf_sink: Sink for the perf-log middleware; ``None`` makes it a
|
|
156
|
+
zero-overhead no-op.
|
|
157
|
+
on_startup: Awaited after the catalog opens — the place for cache
|
|
158
|
+
warmers. Exceptions propagate and will fail startup, so a
|
|
159
|
+
best-effort warmer must swallow its own errors.
|
|
160
|
+
on_shutdown: Called after the app stops, instead of the default
|
|
161
|
+
``runtime.close()``. Supply one only if you need to close something
|
|
162
|
+
more than the runtime; it takes over responsibility for doing so.
|
|
163
|
+
root_payload: JSON body for ``GET /``. Defaults to a service identity.
|
|
164
|
+
"""
|
|
165
|
+
# Routes DEFLATE through isa-l instead of stdlib zlib — same wire format,
|
|
166
|
+
# ~2-3x throughput at the low compression levels we use. Idempotent.
|
|
167
|
+
install_isal_zip_backend()
|
|
168
|
+
|
|
169
|
+
@asynccontextmanager
|
|
170
|
+
async def _lifespan(app_: FastAPI) -> AsyncGenerator[None]:
|
|
171
|
+
logger.info("opening catalog")
|
|
172
|
+
runtime.ensure_open()
|
|
173
|
+
if on_startup is not None:
|
|
174
|
+
await on_startup(app_)
|
|
175
|
+
yield
|
|
176
|
+
if on_shutdown is not None:
|
|
177
|
+
on_shutdown()
|
|
178
|
+
else:
|
|
179
|
+
runtime.close()
|
|
180
|
+
logger.info("catalog closed")
|
|
181
|
+
|
|
182
|
+
app = FastAPI(
|
|
183
|
+
title=settings.title,
|
|
184
|
+
version=settings.version,
|
|
185
|
+
description=settings.description,
|
|
186
|
+
# Disabled so the multi-spec `/docs` page below can be served instead —
|
|
187
|
+
# FastAPI's helper only emits a single-spec page.
|
|
188
|
+
docs_url=None,
|
|
189
|
+
redoc_url=None,
|
|
190
|
+
lifespan=_lifespan,
|
|
191
|
+
# Reject unknown query params with 400 rather than silently ignoring
|
|
192
|
+
# them. App-wide; the dependency is one declared-set build per request.
|
|
193
|
+
dependencies=[Depends(reject_unknown_query_params)],
|
|
194
|
+
)
|
|
195
|
+
_apply_state(app, state)
|
|
196
|
+
attach_runtime(app, runtime)
|
|
197
|
+
|
|
198
|
+
_install_middleware(app, runtime, settings, perf_sink=perf_sink, outer=outer_middleware)
|
|
199
|
+
|
|
200
|
+
for router in routers:
|
|
201
|
+
app.include_router(router)
|
|
202
|
+
|
|
203
|
+
for mount in mounts:
|
|
204
|
+
_apply_state(mount.app, state)
|
|
205
|
+
share_runtime(app, mount.app)
|
|
206
|
+
app.mount(mount.path, mount.app)
|
|
207
|
+
|
|
208
|
+
# Mark vendor params/paths with `x-vendor: true` so SDK generators can
|
|
209
|
+
# filter them out for a spec-conformant client. Wraps each app's `openapi`
|
|
210
|
+
# method; the marker pass mutates FastAPI's cached dict in place.
|
|
211
|
+
_install_vendor_marker_hook(app)
|
|
212
|
+
for mount in mounts:
|
|
213
|
+
_install_vendor_marker_hook(mount.app)
|
|
214
|
+
|
|
215
|
+
_install_docs(app, settings, mounts)
|
|
216
|
+
|
|
217
|
+
payload = (
|
|
218
|
+
dict(root_payload)
|
|
219
|
+
if root_payload is not None
|
|
220
|
+
else {
|
|
221
|
+
"service": settings.title,
|
|
222
|
+
"version": settings.version,
|
|
223
|
+
"docs": "/docs",
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
@app.get("/", include_in_schema=False)
|
|
228
|
+
async def _root() -> JSONResponse:
|
|
229
|
+
return JSONResponse(payload)
|
|
230
|
+
|
|
231
|
+
return app
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _apply_state(app: FastAPI, state: Mapping[str, Any] | None) -> None:
|
|
235
|
+
for key, value in (state or {}).items():
|
|
236
|
+
setattr(app.state, key, value)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _install_middleware(
|
|
240
|
+
app: FastAPI,
|
|
241
|
+
runtime: CatalogRuntime,
|
|
242
|
+
settings: RestSettings,
|
|
243
|
+
*,
|
|
244
|
+
perf_sink: Callable[..., None] | None,
|
|
245
|
+
outer: Sequence[MiddlewareSpec],
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Build the middleware stack — the order here is an invariant.
|
|
248
|
+
|
|
249
|
+
Starlette wraps inside-out: the LAST middleware added is the OUTERMOST and
|
|
250
|
+
runs first on the way in, last on the way out. Reading the calls below
|
|
251
|
+
top-to-bottom therefore walks from the routes outward. Each adjacency
|
|
252
|
+
exists for a reason:
|
|
253
|
+
|
|
254
|
+
- **ConditionalGet innermost** — its ETag hashes the *uncompressed* body,
|
|
255
|
+
so it has to run before GZip on the way back out. Move it outward and
|
|
256
|
+
the ETag starts varying with compression settings.
|
|
257
|
+
- **LangOverride outside ConditionalGet** — ``?lang=`` rewrites the
|
|
258
|
+
Accept-Language header, and that rewrite must land before the ETag layer
|
|
259
|
+
reads the header, so ``?lang=fr`` hashes differently from the equivalent
|
|
260
|
+
English request on the same URL.
|
|
261
|
+
- **GZip outside the version-advertise rewrite** — the rewrite edits
|
|
262
|
+
Content-Type, which the compression decision reads.
|
|
263
|
+
- **HeadMethod inside the logging taps** — it rewrites HEAD to GET so the
|
|
264
|
+
ETag / GZip / version layers see a normal GET and the 304 short-circuit
|
|
265
|
+
behaves identically. The taps stay outside it so they still observe the
|
|
266
|
+
client's *original* method.
|
|
267
|
+
- **CORS outermost** — so it answers every OPTIONS preflight directly, even
|
|
268
|
+
for paths the app doesn't route. A preflight that falls through to a 404
|
|
269
|
+
reads to the browser as a CORS failure.
|
|
270
|
+
"""
|
|
271
|
+
app.add_middleware(
|
|
272
|
+
ConditionalGetMiddleware,
|
|
273
|
+
runtime=runtime,
|
|
274
|
+
cache_control=settings.cache_control,
|
|
275
|
+
version=settings.version,
|
|
276
|
+
)
|
|
277
|
+
app.add_middleware(LangOverrideMiddleware)
|
|
278
|
+
# sdmxlib's writers emit three-part media-type versions (`;version=2.0.0`);
|
|
279
|
+
# SDMX-REST 2.2.2 §6.1 advertises the two-part form (`;version=2.0`), and
|
|
280
|
+
# strict clients compare Content-Type literally. Rewrites only the version
|
|
281
|
+
# segment on the way out; `ensure_acceptable` already treats both as
|
|
282
|
+
# equivalent on the way in, so three-part clients keep working.
|
|
283
|
+
app.add_middleware(SdmxVersionAdvertiseMiddleware)
|
|
284
|
+
app.add_middleware(
|
|
285
|
+
SelectiveGZipMiddleware,
|
|
286
|
+
minimum_size=settings.gzip_minimum_size,
|
|
287
|
+
compresslevel=settings.gzip_compress_level,
|
|
288
|
+
)
|
|
289
|
+
app.add_middleware(HeadMethodMiddleware)
|
|
290
|
+
# Outermost of the taps: `wall_ms` then covers CORS, compression, ETag,
|
|
291
|
+
# routing and body render. A `None` sink short-circuits at zero cost.
|
|
292
|
+
app.add_middleware(PerfLogMiddleware, sink=perf_sink)
|
|
293
|
+
for spec in outer:
|
|
294
|
+
app.add_middleware(spec.cls, **dict(spec.options))
|
|
295
|
+
app.add_middleware(
|
|
296
|
+
CORSMiddleware,
|
|
297
|
+
allow_origins=list(settings.cors_allowed_origins),
|
|
298
|
+
# Required by CORS to coexist with `allow_origins=["*"]`. Revisit if
|
|
299
|
+
# the service ever grows real auth.
|
|
300
|
+
allow_credentials=False,
|
|
301
|
+
allow_methods=list(_DEFAULT_ALLOW_METHODS),
|
|
302
|
+
# Wildcard rather than a curated list: real clients send Cache-Control,
|
|
303
|
+
# Pragma and custom X-* headers on top of the SDMX basics, and a
|
|
304
|
+
# curated list turns any extra header into a preflight 400 that
|
|
305
|
+
# browsers surface as "preflight doesn't pass access control check".
|
|
306
|
+
# Safe because `allow_credentials=False`.
|
|
307
|
+
allow_headers=["*"],
|
|
308
|
+
expose_headers=list(_DEFAULT_EXPOSE_HEADERS),
|
|
309
|
+
max_age=settings.cors_max_age,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _install_vendor_marker_hook(app: FastAPI) -> None:
|
|
314
|
+
"""Wrap ``app.openapi`` so vendor params/paths get ``x-vendor: true``.
|
|
315
|
+
|
|
316
|
+
FastAPI memoises the schema on ``app.openapi_schema``; the dict is mutated
|
|
317
|
+
in place once, and later calls return the already-marked cache.
|
|
318
|
+
"""
|
|
319
|
+
original = app.openapi
|
|
320
|
+
|
|
321
|
+
def _openapi_with_vendor() -> dict[str, Any]:
|
|
322
|
+
spec = original()
|
|
323
|
+
if not isinstance(spec, dict):
|
|
324
|
+
return spec
|
|
325
|
+
if not spec.get("x-vendor-markers-applied"):
|
|
326
|
+
apply_vendor_markers(spec)
|
|
327
|
+
spec["x-vendor-markers-applied"] = True
|
|
328
|
+
return spec
|
|
329
|
+
|
|
330
|
+
app.openapi = _openapi_with_vendor # type: ignore[method-assign]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _install_docs(app: FastAPI, settings: RestSettings, mounts: Sequence[Mount]) -> None:
|
|
334
|
+
"""Serve a hand-rolled Swagger UI page with a spec dropdown.
|
|
335
|
+
|
|
336
|
+
FastAPI's ``get_swagger_ui_html`` always emits a single-spec ``url:`` line;
|
|
337
|
+
Swagger UI given both ``url`` and ``urls`` renders "No API definition
|
|
338
|
+
provided", so the page is built here instead. Three details are load-bearing
|
|
339
|
+
for the dropdown to appear at all: the standalone-preset script, the
|
|
340
|
+
``StandaloneLayout`` layout, and ``SwaggerUIStandalonePreset`` in ``presets``.
|
|
341
|
+
"""
|
|
342
|
+
specs = [(settings.canonical_spec_name, "/openapi.json")]
|
|
343
|
+
specs.extend((m.spec_name, f"{m.path}/openapi.json") for m in mounts if m.spec_name)
|
|
344
|
+
urls = ",\n".join(f' {{name: "{name}", url: "{url}"}}' for name, url in specs)
|
|
345
|
+
|
|
346
|
+
html = (
|
|
347
|
+
"<!DOCTYPE html>\n"
|
|
348
|
+
"<html>\n"
|
|
349
|
+
"<head>\n"
|
|
350
|
+
f" <title>{app.title} — API docs</title>\n"
|
|
351
|
+
' <link rel="stylesheet" type="text/css" '
|
|
352
|
+
'href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">\n'
|
|
353
|
+
"</head>\n"
|
|
354
|
+
"<body>\n"
|
|
355
|
+
' <div id="swagger-ui"></div>\n'
|
|
356
|
+
' <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>\n'
|
|
357
|
+
' <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>\n'
|
|
358
|
+
" <script>\n"
|
|
359
|
+
" window.ui = SwaggerUIBundle({\n"
|
|
360
|
+
' dom_id: "#swagger-ui",\n'
|
|
361
|
+
' layout: "StandaloneLayout",\n'
|
|
362
|
+
" deepLinking: true,\n"
|
|
363
|
+
" showExtensions: true,\n"
|
|
364
|
+
" showCommonExtensions: true,\n"
|
|
365
|
+
" urls: [\n"
|
|
366
|
+
f"{urls}\n"
|
|
367
|
+
" ],\n"
|
|
368
|
+
f' "urls.primaryName": "{settings.canonical_spec_name}",\n'
|
|
369
|
+
# Drop the bottom-right validator badge — it pings validator.swagger.io,
|
|
370
|
+
# which can't reach a private cluster, so it always renders a spurious
|
|
371
|
+
# warning.
|
|
372
|
+
" validatorUrl: null,\n"
|
|
373
|
+
" presets: [\n"
|
|
374
|
+
" SwaggerUIBundle.presets.apis,\n"
|
|
375
|
+
" SwaggerUIStandalonePreset\n"
|
|
376
|
+
" ]\n"
|
|
377
|
+
" });\n"
|
|
378
|
+
" </script>\n"
|
|
379
|
+
"</body>\n"
|
|
380
|
+
"</html>\n"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
@app.get("/docs", include_in_schema=False)
|
|
384
|
+
async def _swagger_ui() -> HTMLResponse:
|
|
385
|
+
return HTMLResponse(html)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
__all__ = ["MiddlewareSpec", "Mount", "RestSettings", "create_app", "library_routers"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The availability-response cache seam.
|
|
2
|
+
|
|
3
|
+
``/availability`` runs at minimum one ``SELECT DISTINCT`` per component against
|
|
4
|
+
the lake, and the result is fully determined by the request URL plus the
|
|
5
|
+
content-negotiation headers — the bundle is immutable per ``build_id``, so the
|
|
6
|
+
same key maps to the same bytes for as long as that build is served. That makes
|
|
7
|
+
the response an obvious cache candidate, but *whether* it is worth caching, how
|
|
8
|
+
many entries to hold, and where the bytes live are all consumer decisions: a
|
|
9
|
+
catalog whose flows are small enough to answer in milliseconds should not pay
|
|
10
|
+
for an LRU it never hits.
|
|
11
|
+
|
|
12
|
+
So the library only knows to ask the injected :class:`AvailabilityCache` before
|
|
13
|
+
doing work, and to offer the finished response back for storage. The default
|
|
14
|
+
:class:`NullAvailabilityCache` does neither, so a bare catalog simply always
|
|
15
|
+
runs the query. The cache is carried on the
|
|
16
|
+
:class:`~sdmxlib_rest.runtime.CatalogRuntime`, alongside
|
|
17
|
+
:class:`~sdmxlib_rest.structure_cache.StructureCache`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
23
|
+
|
|
24
|
+
from attrs import frozen
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from fastapi.responses import Response
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@frozen
|
|
31
|
+
class AvailabilityQuery:
|
|
32
|
+
"""The resolved availability request a cache keys on.
|
|
33
|
+
|
|
34
|
+
Everything that shapes the body is in the URL, so ``path`` + ``query`` (the
|
|
35
|
+
raw query string) identify the computation and the two headers cover
|
|
36
|
+
content negotiation. ``build_id`` pins the entry to the immutable bundle
|
|
37
|
+
build, so a re-publish invalidates every slot.
|
|
38
|
+
|
|
39
|
+
Keying on the raw path rather than the parsed flow coordinates matters: the
|
|
40
|
+
1.5 ``/availableconstraint`` forwarders synthesise a per-flow stub path
|
|
41
|
+
precisely so two flows cannot collide in one slot.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
build_id: str
|
|
45
|
+
path: str
|
|
46
|
+
query: str
|
|
47
|
+
accept: str | None
|
|
48
|
+
accept_language: str | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class AvailabilityCache(Protocol):
|
|
53
|
+
"""Consumer-owned cache for rendered availability responses.
|
|
54
|
+
|
|
55
|
+
``lookup`` returns a ready response to short-circuit with, or ``None`` to
|
|
56
|
+
proceed with the live query. ``store`` is offered the finished response for
|
|
57
|
+
the consumer to cache if its own gating says the query is worth it —
|
|
58
|
+
including the empty 204, which is as expensive to derive as a full answer.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def lookup(self, query: AvailabilityQuery) -> Response | None:
|
|
62
|
+
"""Return a cached response for ``query``, or ``None`` to run it live."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def store(self, query: AvailabilityQuery, response: Response) -> None:
|
|
66
|
+
"""Offer a freshly built response for caching (consumer decides)."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NullAvailabilityCache:
|
|
70
|
+
"""Default no-op cache — never hits, never stores."""
|
|
71
|
+
|
|
72
|
+
def lookup(self, query: AvailabilityQuery) -> Response | None: # noqa: ARG002
|
|
73
|
+
"""Never a hit."""
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def store(self, query: AvailabilityQuery, response: Response) -> None:
|
|
77
|
+
"""No-op."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["AvailabilityCache", "AvailabilityQuery", "NullAvailabilityCache"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Generic building blocks for an SDMX-REST data plane.
|
|
2
|
+
|
|
3
|
+
The library deliberately does **not** own the ``/data`` route: orchestration
|
|
4
|
+
is where catalog-specific caching, partition pushdown and vendor extensions
|
|
5
|
+
live, and every catalog shapes those differently. What it does own is the
|
|
6
|
+
reusable middle — query-parameter parsing, WHERE construction, projection
|
|
7
|
+
resolution, paging headers and the arrow→CSV/parquet streaming path.
|
|
8
|
+
|
|
9
|
+
A consumer writes its own handler and calls into these. See issue #34 for
|
|
10
|
+
the decision and its rationale.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from sdmxlib_rest.data.encoding import (
|
|
16
|
+
emit_csv_with_labels,
|
|
17
|
+
encode_parquet,
|
|
18
|
+
prepend_utf8_bom,
|
|
19
|
+
stream_csv_zip,
|
|
20
|
+
)
|
|
21
|
+
from sdmxlib_rest.data.paging import (
|
|
22
|
+
RANGE_RE,
|
|
23
|
+
build_range_headers,
|
|
24
|
+
parse_items_range,
|
|
25
|
+
range_status,
|
|
26
|
+
with_range,
|
|
27
|
+
)
|
|
28
|
+
from sdmxlib_rest.data.params import (
|
|
29
|
+
AGENCY_PATTERN,
|
|
30
|
+
C_OPERATORS,
|
|
31
|
+
C_PARAM,
|
|
32
|
+
DROP_ALL_ATTRIBUTE_KEYWORDS,
|
|
33
|
+
ID_PATTERN,
|
|
34
|
+
KEEP_ALL_ATTRIBUTE_KEYWORDS,
|
|
35
|
+
KEEP_ALL_MEASURE_KEYWORDS,
|
|
36
|
+
LIKE_OPS,
|
|
37
|
+
NON_EQ_OPERATOR_SQL,
|
|
38
|
+
VERSION_PATTERN,
|
|
39
|
+
build_non_eq_where,
|
|
40
|
+
like_pattern,
|
|
41
|
+
parse_bracketed,
|
|
42
|
+
parse_c_params,
|
|
43
|
+
parse_sort,
|
|
44
|
+
reject_unsupported_standard_params,
|
|
45
|
+
sql_ident,
|
|
46
|
+
validate_dimension_at_observation,
|
|
47
|
+
)
|
|
48
|
+
from sdmxlib_rest.data.projection import (
|
|
49
|
+
key_dimension_ids,
|
|
50
|
+
label_maps_for_dims,
|
|
51
|
+
materialise_dsd_for_flow,
|
|
52
|
+
project_arrow_batches,
|
|
53
|
+
project_binding,
|
|
54
|
+
resolve_projection_set,
|
|
55
|
+
)
|
|
56
|
+
from sdmxlib_rest.data.sql import decimals_format_replace_clause, qualify_is_active
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"AGENCY_PATTERN",
|
|
60
|
+
"C_OPERATORS",
|
|
61
|
+
"C_PARAM",
|
|
62
|
+
"DROP_ALL_ATTRIBUTE_KEYWORDS",
|
|
63
|
+
"ID_PATTERN",
|
|
64
|
+
"KEEP_ALL_ATTRIBUTE_KEYWORDS",
|
|
65
|
+
"KEEP_ALL_MEASURE_KEYWORDS",
|
|
66
|
+
"LIKE_OPS",
|
|
67
|
+
"NON_EQ_OPERATOR_SQL",
|
|
68
|
+
"RANGE_RE",
|
|
69
|
+
"VERSION_PATTERN",
|
|
70
|
+
"build_non_eq_where",
|
|
71
|
+
"build_range_headers",
|
|
72
|
+
"decimals_format_replace_clause",
|
|
73
|
+
"emit_csv_with_labels",
|
|
74
|
+
"encode_parquet",
|
|
75
|
+
"key_dimension_ids",
|
|
76
|
+
"label_maps_for_dims",
|
|
77
|
+
"like_pattern",
|
|
78
|
+
"materialise_dsd_for_flow",
|
|
79
|
+
"parse_bracketed",
|
|
80
|
+
"parse_c_params",
|
|
81
|
+
"parse_items_range",
|
|
82
|
+
"parse_sort",
|
|
83
|
+
"prepend_utf8_bom",
|
|
84
|
+
"project_arrow_batches",
|
|
85
|
+
"project_binding",
|
|
86
|
+
"qualify_is_active",
|
|
87
|
+
"range_status",
|
|
88
|
+
"reject_unsupported_standard_params",
|
|
89
|
+
"resolve_projection_set",
|
|
90
|
+
"sql_ident",
|
|
91
|
+
"stream_csv_zip",
|
|
92
|
+
"validate_dimension_at_observation",
|
|
93
|
+
"with_range",
|
|
94
|
+
]
|