Skip to content

Commit 72fe743

Browse files
Merge pull request #276 from OpenSPP/reland/api-v2-core
feat(spp_api_v2): OpenAPI polymorphic bodies, OAuth2 scheme in auth middleware, bundle schemas (re-land from #76)
2 parents d850155 + 72c2141 commit 72fe743

15 files changed

Lines changed: 636 additions & 30 deletions

File tree

spp_api_v2/README.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,31 @@ Dependencies
147147
Changelog
148148
=========
149149

150+
19.0.2.1.0
151+
~~~~~~~~~~
152+
153+
- Add OpenAPI polymorphic schema utilities
154+
(``utils/openapi_polymorphic.py``): ``polymorphic_body()`` for
155+
declaring dict-typed fields that accept one of several Pydantic
156+
models, plus an app-level OpenAPI hook that injects the corresponding
157+
``oneOf`` schemas into the generated document
158+
- Auth middleware: replace the plain ``HTTPBearer`` scheme with an
159+
OAuth2 client-credentials security scheme so the OpenAPI document
160+
advertises the token endpoint and consumers (Swagger UI, QGIS, etc.)
161+
can discover how to authenticate. The advertised ``tokenUrl`` is
162+
absolutized against the endpoint's mount path at generation time so
163+
strict RFC 3986 clients resolve it correctly. The ``Bearer`` prefix is
164+
stripped from the Authorization header when present; a raw token
165+
without the prefix is also accepted
166+
- Bundle schemas: registrant-serving endpoints document bundle entries
167+
as polymorphic Individual/Group bodies via new
168+
``RegistrantBundle``/``RegistrantBundleEntry`` subtypes, so their
169+
payloads are fully described in the OpenAPI document; the shared
170+
``BundleEntry`` stays generic because other modules reuse it for
171+
non-registrant resources
172+
- Add OpenAPI contract tests covering bundle schema rendering, the
173+
polymorphic utilities, and the overall OpenAPI document contract
174+
150175
19.0.2.0.1
151176
~~~~~~~~~~
152177

spp_api_v2/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "OpenSPP API V2",
33
"category": "OpenSPP/Integration",
4-
"version": "19.0.2.0.1",
4+
"version": "19.0.2.1.0",
55
"sequence": 1,
66
"author": "OpenSPP.org",
77
"website": "https://github.com/OpenSPP/OpenSPP2",

spp_api_v2/middleware/auth.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,57 @@
1010
from odoo.addons.fastapi.dependencies import odoo_env
1111

1212
from fastapi import Depends, HTTPException, status
13-
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
13+
from fastapi.security import OAuth2
1414

1515
_logger = logging.getLogger(__name__)
1616

17-
# HTTP Bearer scheme for extracting token from Authorization header
18-
# auto_error=False allows us to handle authentication errors with proper status codes
19-
security = HTTPBearer(auto_error=False)
17+
18+
# OAuth2 Client Credentials scheme.
19+
# This produces an "oauth2" entry in the OpenAPI securitySchemes with the
20+
# clientCredentials flow pointing at our token endpoint, so API consumers
21+
# (Swagger UI, QGIS, etc.) can discover how to authenticate.
22+
# auto_error=False allows us to handle authentication errors with proper status codes.
23+
def absolutize_oauth_token_urls(app, root_path: str) -> None:
24+
"""Rewrite relative OAuth2 tokenUrls to the endpoint's absolute path.
25+
26+
The security scheme below is a module-level constant, created before any
27+
mount path is known, so its tokenUrl is relative. Per RFC 3986 a strict
28+
client resolves "oauth/token" against the server URL "/api/v2/spp" to
29+
"/api/v2/oauth/token" (404). This wraps the app's OpenAPI generator and
30+
absolutizes the advertised URL against the endpoint's root_path.
31+
"""
32+
inner = app.openapi
33+
34+
def openapi_with_absolute_token_urls():
35+
schema = inner()
36+
schemes = schema.get("components", {}).get("securitySchemes", {})
37+
for scheme in schemes.values():
38+
for flow in scheme.get("flows", {}).values():
39+
url = flow.get("tokenUrl")
40+
if url and "://" not in url and not url.startswith("/"):
41+
flow["tokenUrl"] = f"{root_path.rstrip('/')}/{url}"
42+
return schema
43+
44+
app.openapi = openapi_with_absolute_token_urls
45+
46+
47+
security = OAuth2(
48+
flows={
49+
"clientCredentials": {
50+
"tokenUrl": "oauth/token",
51+
"scopes": {},
52+
},
53+
},
54+
auto_error=False,
55+
)
2056

2157
# Cache for JWT secret validation results, keyed by hash of the secret.
2258
# Avoids recomputing Shannon entropy on every API request.
2359
_validated_jwt_secrets: set[str] = set()
2460

2561

2662
def get_authenticated_client(
27-
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
63+
token: Annotated[str | None, Depends(security)],
2864
env: Annotated[Environment, Depends(odoo_env)],
2965
):
3066
"""
@@ -39,14 +75,17 @@ def get_authenticated_client(
3975
Raises:
4076
HTTPException: If token is invalid, expired, or client not found
4177
"""
42-
if not credentials:
78+
if not token:
4379
raise HTTPException(
4480
status_code=status.HTTP_401_UNAUTHORIZED,
4581
detail="Missing Authorization header",
4682
headers={"WWW-Authenticate": "Bearer"},
4783
)
4884

49-
token = credentials.credentials
85+
# OAuth2 dependency returns the full Authorization header value
86+
# (e.g. "Bearer eyJ..."). Strip the scheme prefix to get the raw JWT.
87+
if token.lower().startswith("bearer "):
88+
token = token[7:].strip()
5089

5190
try:
5291
# Decode and validate JWT
@@ -91,7 +130,7 @@ def get_authenticated_client(
91130

92131

93132
def get_current_client(
94-
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
133+
token: Annotated[str | None, Depends(security)],
95134
env: Annotated[Environment, Depends(odoo_env)],
96135
) -> dict:
97136
"""
@@ -103,7 +142,7 @@ def get_current_client(
103142
Returns:
104143
dict: {"env": Environment, "client": spp.api.client record}
105144
"""
106-
client = get_authenticated_client(credentials, env)
145+
client = get_authenticated_client(token, env)
107146
return {"env": env, "client": client}
108147

109148

spp_api_v2/models/fastapi_endpoint_registry.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from fastapi import APIRouter, FastAPI
77

8+
from ..utils.openapi_polymorphic import install_polymorphic_openapi_hook
9+
810
_logger = logging.getLogger(__name__)
911

1012

@@ -79,6 +81,13 @@ def _get_app(self) -> FastAPI:
7981
from ..middleware.version import VersionMiddleware
8082

8183
app.add_middleware(VersionMiddleware)
84+
# Install OpenAPI hook so polymorphic_body() schemas are injected
85+
install_polymorphic_openapi_hook(app)
86+
# Advertise the token endpoint with an absolute path (strict
87+
# RFC 3986 clients would resolve a relative one to a 404).
88+
from ..middleware.auth import absolutize_oauth_token_urls
89+
90+
absolutize_oauth_token_urls(app, self.root_path or "")
8291
# V2 API uses public endpoint with JWT authentication in middleware
8392
# No default authentication required at app level
8493
return app

spp_api_v2/readme/HISTORY.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
### 19.0.2.1.0
2+
3+
- Add OpenAPI polymorphic schema utilities (`utils/openapi_polymorphic.py`): `polymorphic_body()` for declaring dict-typed fields that accept one of several Pydantic models, plus an app-level OpenAPI hook that injects the corresponding `oneOf` schemas into the generated document
4+
- Auth middleware: replace the plain `HTTPBearer` scheme with an OAuth2 client-credentials security scheme so the OpenAPI document advertises the token endpoint and consumers (Swagger UI, QGIS, etc.) can discover how to authenticate. The advertised `tokenUrl` is absolutized against the endpoint's mount path at generation time so strict RFC 3986 clients resolve it correctly. The `Bearer` prefix is stripped from the Authorization header when present; a raw token without the prefix is also accepted
5+
- Bundle schemas: registrant-serving endpoints document bundle entries as polymorphic Individual/Group bodies via new `RegistrantBundle`/`RegistrantBundleEntry` subtypes, so their payloads are fully described in the OpenAPI document; the shared `BundleEntry` stays generic because other modules reuse it for non-registrant resources
6+
- Add OpenAPI contract tests covering bundle schema rendering, the polymorphic utilities, and the overall OpenAPI document contract
7+
18
### 19.0.2.0.1
29

310
- Fix `SerializationFailure` race when multiple Odoo workers rebuild their routing map simultaneously (e.g. after `-u all`) and all try to sync the same `fastapi.endpoint` rows

spp_api_v2/routers/batch.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@
1212
from fastapi import APIRouter, Depends, HTTPException, status
1313

1414
from ..middleware.auth import get_authenticated_client
15-
from ..schemas.bundle import Bundle
15+
from ..schemas.bundle import RegistrantBundle
1616
from ..services.bundle_service import BundleProcessor
1717

1818
_logger = logging.getLogger(__name__)
1919

2020
batch_router = APIRouter(tags=["Batch"], prefix="/$batch")
2121

2222

23-
@batch_router.post("", response_model=Bundle)
23+
@batch_router.post("", response_model=RegistrantBundle)
2424
async def process_bundle(
25-
bundle: Bundle,
25+
bundle: RegistrantBundle,
2626
env: Annotated[Environment, Depends(odoo_env)],
2727
api_client: Annotated[dict, Depends(get_authenticated_client)],
2828
):

spp_api_v2/routers/filter.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from fastapi import APIRouter, Depends, HTTPException, Query, status
1313

1414
from ..middleware.auth import get_authenticated_client
15-
from ..schemas.bundle import Bundle, BundleEntry, BundleLink, BundleSearch
15+
from ..schemas.bundle import BundleLink, BundleSearch, RegistrantBundle, RegistrantBundleEntry
1616
from ..schemas.filter import FilterMetadataResponse, SearchRequest
1717
from ..services.consent_service import ConsentService
1818
from ..services.filter_service import FilterService
@@ -73,7 +73,7 @@ async def search(
7373
env: Annotated[Environment, Depends(odoo_env)],
7474
api_client: Annotated[object, Depends(get_authenticated_client)],
7575
extensions: Annotated[str | None, Query(alias="_extensions")] = None,
76-
) -> Bundle:
76+
) -> RegistrantBundle:
7777
"""
7878
Advanced search with complex filter conditions.
7979
@@ -194,7 +194,7 @@ async def search(
194194
continue
195195

196196
entries.append(
197-
BundleEntry(
197+
RegistrantBundleEntry(
198198
resource=data,
199199
search=BundleSearch(mode="match", score=1.0),
200200
)
@@ -233,7 +233,7 @@ async def search(
233233
)
234234
)
235235

236-
return Bundle(
236+
return RegistrantBundle(
237237
resourceType="Bundle",
238238
type="searchset",
239239
total=total,
@@ -259,7 +259,7 @@ async def search(
259259
"/_search",
260260
_create_search_endpoint("Individual"),
261261
methods=["POST"],
262-
response_model=Bundle,
262+
response_model=RegistrantBundle,
263263
response_model_exclude_none=True,
264264
summary="Advanced Individual Search",
265265
description="Search individuals with complex filter conditions",
@@ -280,7 +280,7 @@ async def search(
280280
"/_search",
281281
_create_search_endpoint("Group"),
282282
methods=["POST"],
283-
response_model=Bundle,
283+
response_model=RegistrantBundle,
284284
response_model_exclude_none=True,
285285
summary="Advanced Group Search",
286286
description="Search groups with complex filter conditions",

spp_api_v2/schemas/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from . import api_metadata
22
from . import base
33
from . import bulk
4-
from . import bundle
54
from . import filter as filter_schema
65
from . import group
76
from . import individual
7+
from . import bundle
88
from . import membership
99
from . import operation_outcome
1010
from . import patch

spp_api_v2/schemas/bundle.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55

66
from pydantic import BaseModel, ConfigDict, Field
77

8+
from ..utils.openapi_polymorphic import polymorphic_body
9+
from .group import Group
10+
from .individual import Individual
11+
812

913
class BundleLink(BaseModel):
1014
"""Link in a bundle (for pagination)"""
@@ -50,7 +54,10 @@ class BundleEntry(BaseModel):
5054
)
5155
request: BundleRequest | None = None
5256
response: BundleResponse | None = None
53-
resource: dict[str, Any] | None = None
57+
resource: dict[str, Any] | None = Field(
58+
None,
59+
description="FHIR-style resource. Must match the type indicated by request.url.",
60+
)
5461
search: BundleSearch | None = None
5562

5663

@@ -111,6 +118,29 @@ class Bundle(BaseModel):
111118
entry: list[BundleEntry] | None = None
112119

113120

121+
class RegistrantBundleEntry(BundleEntry):
122+
"""Bundle entry whose resource is a registrant (Individual or Group).
123+
124+
The base BundleEntry stays generic because other modules (e.g. Products)
125+
reuse it for non-registrant resources; only registrant-serving endpoints
126+
document the Individual/Group restriction.
127+
"""
128+
129+
resource: dict[str, Any] | None = polymorphic_body(
130+
Individual,
131+
Group,
132+
default=None,
133+
description="FHIR-style registrant resource (Individual or Group). "
134+
"Must match the type indicated by request.url.",
135+
)
136+
137+
138+
class RegistrantBundle(Bundle):
139+
"""Bundle whose entries carry registrant resources (Individual or Group)."""
140+
141+
entry: list[RegistrantBundleEntry] | None = None
142+
143+
114144
# ============================================================================
115145
# New simplified batch schemas (ADR-019)
116146
# ============================================================================

spp_api_v2/static/description/index.html

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -517,18 +517,45 @@ <h1>Dependencies</h1>
517517
<div class="contents local topic" id="contents">
518518
<ul class="simple">
519519
<li><a class="reference internal" href="#changelog" id="toc-entry-1">Changelog</a><ul>
520-
<li><a class="reference internal" href="#section-1" id="toc-entry-2">19.0.2.0.1</a></li>
521-
<li><a class="reference internal" href="#section-2" id="toc-entry-3">19.0.2.0.0</a></li>
520+
<li><a class="reference internal" href="#section-1" id="toc-entry-2">19.0.2.1.0</a></li>
521+
<li><a class="reference internal" href="#section-2" id="toc-entry-3">19.0.2.0.1</a></li>
522+
<li><a class="reference internal" href="#section-3" id="toc-entry-4">19.0.2.0.0</a></li>
522523
</ul>
523524
</li>
524-
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-4">Bug Tracker</a></li>
525-
<li><a class="reference internal" href="#credits" id="toc-entry-5">Credits</a></li>
525+
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-5">Bug Tracker</a></li>
526+
<li><a class="reference internal" href="#credits" id="toc-entry-6">Credits</a></li>
526527
</ul>
527528
</div>
528529
<div class="section" id="changelog">
529530
<h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
530531
<div class="section" id="section-1">
531-
<h3><a class="toc-backref" href="#toc-entry-2">19.0.2.0.1</a></h3>
532+
<h3><a class="toc-backref" href="#toc-entry-2">19.0.2.1.0</a></h3>
533+
<ul class="simple">
534+
<li>Add OpenAPI polymorphic schema utilities
535+
(<tt class="docutils literal">utils/openapi_polymorphic.py</tt>): <tt class="docutils literal">polymorphic_body()</tt> for
536+
declaring dict-typed fields that accept one of several Pydantic
537+
models, plus an app-level OpenAPI hook that injects the corresponding
538+
<tt class="docutils literal">oneOf</tt> schemas into the generated document</li>
539+
<li>Auth middleware: replace the plain <tt class="docutils literal">HTTPBearer</tt> scheme with an
540+
OAuth2 client-credentials security scheme so the OpenAPI document
541+
advertises the token endpoint and consumers (Swagger UI, QGIS, etc.)
542+
can discover how to authenticate. The advertised <tt class="docutils literal">tokenUrl</tt> is
543+
absolutized against the endpoint’s mount path at generation time so
544+
strict RFC 3986 clients resolve it correctly. The <tt class="docutils literal">Bearer</tt> prefix is
545+
stripped from the Authorization header when present; a raw token
546+
without the prefix is also accepted</li>
547+
<li>Bundle schemas: registrant-serving endpoints document bundle entries
548+
as polymorphic Individual/Group bodies via new
549+
<tt class="docutils literal">RegistrantBundle</tt>/<tt class="docutils literal">RegistrantBundleEntry</tt> subtypes, so their
550+
payloads are fully described in the OpenAPI document; the shared
551+
<tt class="docutils literal">BundleEntry</tt> stays generic because other modules reuse it for
552+
non-registrant resources</li>
553+
<li>Add OpenAPI contract tests covering bundle schema rendering, the
554+
polymorphic utilities, and the overall OpenAPI document contract</li>
555+
</ul>
556+
</div>
557+
<div class="section" id="section-2">
558+
<h3><a class="toc-backref" href="#toc-entry-3">19.0.2.0.1</a></h3>
532559
<ul class="simple">
533560
<li>Fix <tt class="docutils literal">SerializationFailure</tt> race when multiple Odoo workers rebuild
534561
their routing map simultaneously (e.g. after <tt class="docutils literal"><span class="pre">-u</span> all</tt>) and all try
@@ -543,23 +570,23 @@ <h3><a class="toc-backref" href="#toc-entry-2">19.0.2.0.1</a></h3>
543570
regressions are diagnosable without raising the global log level</li>
544571
</ul>
545572
</div>
546-
<div class="section" id="section-2">
547-
<h3><a class="toc-backref" href="#toc-entry-3">19.0.2.0.0</a></h3>
573+
<div class="section" id="section-3">
574+
<h3><a class="toc-backref" href="#toc-entry-4">19.0.2.0.0</a></h3>
548575
<ul class="simple">
549576
<li>Initial migration to OpenSPP2</li>
550577
</ul>
551578
</div>
552579
</div>
553580
<div class="section" id="bug-tracker">
554-
<h2><a class="toc-backref" href="#toc-entry-4">Bug Tracker</a></h2>
581+
<h2><a class="toc-backref" href="#toc-entry-5">Bug Tracker</a></h2>
555582
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OpenSPP/OpenSPP2/issues">GitHub Issues</a>.
556583
In case of trouble, please check there if your issue has already been reported.
557584
If you spotted it first, help us to smash it by providing a detailed and welcomed
558585
<a class="reference external" href="https://github.com/OpenSPP/OpenSPP2/issues/new?body=module:%20spp_api_v2%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
559586
<p>Do not contact contributors directly about support or help with technical issues.</p>
560587
</div>
561588
<div class="section" id="credits">
562-
<h2><a class="toc-backref" href="#toc-entry-5">Credits</a></h2>
589+
<h2><a class="toc-backref" href="#toc-entry-6">Credits</a></h2>
563590
</div>
564591
</div>
565592
<div class="section" id="authors">

0 commit comments

Comments
 (0)