Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions spp_api_v2/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,31 @@ Dependencies
Changelog
=========

19.0.2.1.0
~~~~~~~~~~

- 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
- 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
- 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
- Add OpenAPI contract tests covering bundle schema rendering, the
polymorphic utilities, and the overall OpenAPI document contract

19.0.2.0.1
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_api_v2/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "OpenSPP API V2",
"category": "OpenSPP/Integration",
"version": "19.0.2.0.1",
"version": "19.0.2.1.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
57 changes: 48 additions & 9 deletions spp_api_v2/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,57 @@
from odoo.addons.fastapi.dependencies import odoo_env

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.security import OAuth2

_logger = logging.getLogger(__name__)

# HTTP Bearer scheme for extracting token from Authorization header
# auto_error=False allows us to handle authentication errors with proper status codes
security = HTTPBearer(auto_error=False)

# OAuth2 Client Credentials scheme.
# This produces an "oauth2" entry in the OpenAPI securitySchemes with the
# clientCredentials flow pointing at our token endpoint, so API consumers
# (Swagger UI, QGIS, etc.) can discover how to authenticate.
# auto_error=False allows us to handle authentication errors with proper status codes.
def absolutize_oauth_token_urls(app, root_path: str) -> None:
"""Rewrite relative OAuth2 tokenUrls to the endpoint's absolute path.

The security scheme below is a module-level constant, created before any
mount path is known, so its tokenUrl is relative. Per RFC 3986 a strict
client resolves "oauth/token" against the server URL "/api/v2/spp" to
"/api/v2/oauth/token" (404). This wraps the app's OpenAPI generator and
absolutizes the advertised URL against the endpoint's root_path.
"""
inner = app.openapi

def openapi_with_absolute_token_urls():
schema = inner()
schemes = schema.get("components", {}).get("securitySchemes", {})
for scheme in schemes.values():
for flow in scheme.get("flows", {}).values():
url = flow.get("tokenUrl")
if url and "://" not in url and not url.startswith("/"):
flow["tokenUrl"] = f"{root_path.rstrip('/')}/{url}"
return schema

app.openapi = openapi_with_absolute_token_urls


security = OAuth2(
flows={
"clientCredentials": {
"tokenUrl": "oauth/token",
"scopes": {},
},
},
auto_error=False,
)

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


def get_authenticated_client(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
token: Annotated[str | None, Depends(security)],
env: Annotated[Environment, Depends(odoo_env)],
):
"""
Expand All @@ -39,14 +75,17 @@ def get_authenticated_client(
Raises:
HTTPException: If token is invalid, expired, or client not found
"""
if not credentials:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Authorization header",
headers={"WWW-Authenticate": "Bearer"},
)

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

try:
# Decode and validate JWT
Expand Down Expand Up @@ -91,7 +130,7 @@ def get_authenticated_client(


def get_current_client(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
token: Annotated[str | None, Depends(security)],
env: Annotated[Environment, Depends(odoo_env)],
) -> dict:
"""
Expand All @@ -103,7 +142,7 @@ def get_current_client(
Returns:
dict: {"env": Environment, "client": spp.api.client record}
"""
client = get_authenticated_client(credentials, env)
client = get_authenticated_client(token, env)
return {"env": env, "client": client}


Expand Down
9 changes: 9 additions & 0 deletions spp_api_v2/models/fastapi_endpoint_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from fastapi import APIRouter, FastAPI

from ..utils.openapi_polymorphic import install_polymorphic_openapi_hook

_logger = logging.getLogger(__name__)


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

app.add_middleware(VersionMiddleware)
# Install OpenAPI hook so polymorphic_body() schemas are injected
install_polymorphic_openapi_hook(app)
# Advertise the token endpoint with an absolute path (strict
# RFC 3986 clients would resolve a relative one to a 404).
from ..middleware.auth import absolutize_oauth_token_urls

absolutize_oauth_token_urls(app, self.root_path or "")
# V2 API uses public endpoint with JWT authentication in middleware
# No default authentication required at app level
return app
Expand Down
7 changes: 7 additions & 0 deletions spp_api_v2/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
### 19.0.2.1.0

- 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
- 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
- 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
- Add OpenAPI contract tests covering bundle schema rendering, the polymorphic utilities, and the overall OpenAPI document contract

### 19.0.2.0.1

- 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
Expand Down
6 changes: 3 additions & 3 deletions spp_api_v2/routers/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@
from fastapi import APIRouter, Depends, HTTPException, status

from ..middleware.auth import get_authenticated_client
from ..schemas.bundle import Bundle
from ..schemas.bundle import RegistrantBundle
from ..services.bundle_service import BundleProcessor

_logger = logging.getLogger(__name__)

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


@batch_router.post("", response_model=Bundle)
@batch_router.post("", response_model=RegistrantBundle)
async def process_bundle(
bundle: Bundle,
bundle: RegistrantBundle,
env: Annotated[Environment, Depends(odoo_env)],
api_client: Annotated[dict, Depends(get_authenticated_client)],
):
Expand Down
12 changes: 6 additions & 6 deletions spp_api_v2/routers/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status

from ..middleware.auth import get_authenticated_client
from ..schemas.bundle import Bundle, BundleEntry, BundleLink, BundleSearch
from ..schemas.bundle import BundleLink, BundleSearch, RegistrantBundle, RegistrantBundleEntry
from ..schemas.filter import FilterMetadataResponse, SearchRequest
from ..services.consent_service import ConsentService
from ..services.filter_service import FilterService
Expand Down Expand Up @@ -73,7 +73,7 @@ async def search(
env: Annotated[Environment, Depends(odoo_env)],
api_client: Annotated[object, Depends(get_authenticated_client)],
extensions: Annotated[str | None, Query(alias="_extensions")] = None,
) -> Bundle:
) -> RegistrantBundle:
"""
Advanced search with complex filter conditions.

Expand Down Expand Up @@ -194,7 +194,7 @@ async def search(
continue

entries.append(
BundleEntry(
RegistrantBundleEntry(
resource=data,
search=BundleSearch(mode="match", score=1.0),
)
Expand Down Expand Up @@ -233,7 +233,7 @@ async def search(
)
)

return Bundle(
return RegistrantBundle(
resourceType="Bundle",
type="searchset",
total=total,
Expand All @@ -259,7 +259,7 @@ async def search(
"/_search",
_create_search_endpoint("Individual"),
methods=["POST"],
response_model=Bundle,
response_model=RegistrantBundle,
response_model_exclude_none=True,
summary="Advanced Individual Search",
description="Search individuals with complex filter conditions",
Expand All @@ -280,7 +280,7 @@ async def search(
"/_search",
_create_search_endpoint("Group"),
methods=["POST"],
response_model=Bundle,
response_model=RegistrantBundle,
response_model_exclude_none=True,
summary="Advanced Group Search",
description="Search groups with complex filter conditions",
Expand Down
2 changes: 1 addition & 1 deletion spp_api_v2/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from . import api_metadata
from . import base
from . import bulk
from . import bundle
from . import filter as filter_schema
from . import group
from . import individual
from . import bundle
from . import membership
from . import operation_outcome
from . import patch
Expand Down
32 changes: 31 additions & 1 deletion spp_api_v2/schemas/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

from pydantic import BaseModel, ConfigDict, Field

from ..utils.openapi_polymorphic import polymorphic_body
from .group import Group
from .individual import Individual


class BundleLink(BaseModel):
"""Link in a bundle (for pagination)"""
Expand Down Expand Up @@ -50,7 +54,10 @@ class BundleEntry(BaseModel):
)
request: BundleRequest | None = None
response: BundleResponse | None = None
resource: dict[str, Any] | None = None
resource: dict[str, Any] | None = Field(
None,
description="FHIR-style resource. Must match the type indicated by request.url.",
)
search: BundleSearch | None = None


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


class RegistrantBundleEntry(BundleEntry):
"""Bundle entry whose resource is a registrant (Individual or Group).

The base BundleEntry stays generic because other modules (e.g. Products)
reuse it for non-registrant resources; only registrant-serving endpoints
document the Individual/Group restriction.
"""

resource: dict[str, Any] | None = polymorphic_body(
Individual,
Group,
default=None,
description="FHIR-style registrant resource (Individual or Group). "
"Must match the type indicated by request.url.",
)


class RegistrantBundle(Bundle):
"""Bundle whose entries carry registrant resources (Individual or Group)."""

entry: list[RegistrantBundleEntry] | None = None


# ============================================================================
# New simplified batch schemas (ADR-019)
# ============================================================================
Expand Down
45 changes: 36 additions & 9 deletions spp_api_v2/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -517,18 +517,45 @@ <h1>Dependencies</h1>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#changelog" id="toc-entry-1">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-2">19.0.2.0.1</a></li>
<li><a class="reference internal" href="#section-2" id="toc-entry-3">19.0.2.0.0</a></li>
<li><a class="reference internal" href="#section-1" id="toc-entry-2">19.0.2.1.0</a></li>
<li><a class="reference internal" href="#section-2" id="toc-entry-3">19.0.2.0.1</a></li>
<li><a class="reference internal" href="#section-3" id="toc-entry-4">19.0.2.0.0</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-4">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-5">Credits</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-5">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-6">Credits</a></li>
</ul>
</div>
<div class="section" id="changelog">
<h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
<div class="section" id="section-1">
<h3><a class="toc-backref" href="#toc-entry-2">19.0.2.0.1</a></h3>
<h3><a class="toc-backref" href="#toc-entry-2">19.0.2.1.0</a></h3>
<ul class="simple">
<li>Add OpenAPI polymorphic schema utilities
(<tt class="docutils literal">utils/openapi_polymorphic.py</tt>): <tt class="docutils literal">polymorphic_body()</tt> for
declaring dict-typed fields that accept one of several Pydantic
models, plus an app-level OpenAPI hook that injects the corresponding
<tt class="docutils literal">oneOf</tt> schemas into the generated document</li>
<li>Auth middleware: replace the plain <tt class="docutils literal">HTTPBearer</tt> 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 <tt class="docutils literal">tokenUrl</tt> is
absolutized against the endpoint’s mount path at generation time so
strict RFC 3986 clients resolve it correctly. The <tt class="docutils literal">Bearer</tt> prefix is
stripped from the Authorization header when present; a raw token
without the prefix is also accepted</li>
<li>Bundle schemas: registrant-serving endpoints document bundle entries
as polymorphic Individual/Group bodies via new
<tt class="docutils literal">RegistrantBundle</tt>/<tt class="docutils literal">RegistrantBundleEntry</tt> subtypes, so their
payloads are fully described in the OpenAPI document; the shared
<tt class="docutils literal">BundleEntry</tt> stays generic because other modules reuse it for
non-registrant resources</li>
<li>Add OpenAPI contract tests covering bundle schema rendering, the
polymorphic utilities, and the overall OpenAPI document contract</li>
</ul>
</div>
<div class="section" id="section-2">
<h3><a class="toc-backref" href="#toc-entry-3">19.0.2.0.1</a></h3>
<ul class="simple">
<li>Fix <tt class="docutils literal">SerializationFailure</tt> race when multiple Odoo workers rebuild
their routing map simultaneously (e.g. after <tt class="docutils literal"><span class="pre">-u</span> all</tt>) and all try
Expand All @@ -543,23 +570,23 @@ <h3><a class="toc-backref" href="#toc-entry-2">19.0.2.0.1</a></h3>
regressions are diagnosable without raising the global log level</li>
</ul>
</div>
<div class="section" id="section-2">
<h3><a class="toc-backref" href="#toc-entry-3">19.0.2.0.0</a></h3>
<div class="section" id="section-3">
<h3><a class="toc-backref" href="#toc-entry-4">19.0.2.0.0</a></h3>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
</ul>
</div>
</div>
<div class="section" id="bug-tracker">
<h2><a class="toc-backref" href="#toc-entry-4">Bug Tracker</a></h2>
<h2><a class="toc-backref" href="#toc-entry-5">Bug Tracker</a></h2>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OpenSPP/OpenSPP2/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<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>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h2><a class="toc-backref" href="#toc-entry-5">Credits</a></h2>
<h2><a class="toc-backref" href="#toc-entry-6">Credits</a></h2>
</div>
</div>
<div class="section" id="authors">
Expand Down
Loading
Loading