Skip to content
Open
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
19 changes: 19 additions & 0 deletions spp_api_v2/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,25 @@ 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
``anyOf`` 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; strip the ``Bearer`` prefix from the
raw Authorization header value
- Bundle schemas: document ``BundleEntry.resource`` as a polymorphic
Individual/Group body instead of an opaque dict, so bundle payloads
are fully described in the OpenAPI document
- 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
32 changes: 23 additions & 9 deletions spp_api_v2/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,32 @@
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.
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 +50,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:]
Comment on lines +62 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Slicing the token with token[7:] when stripping the "bearer " prefix can leave leading or trailing whitespace if the client sent multiple spaces (e.g., "Bearer eyJ..."). This can cause JWT decoding to fail.

Applying .strip() ensures that any extra whitespace is safely removed.

Suggested change
if token.lower().startswith("bearer "):
token = token[7:]
if token.lower().startswith("bearer "):
token = token[7:].strip()


try:
# Decode and validate JWT
Expand Down Expand Up @@ -91,7 +105,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 +117,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
4 changes: 4 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,8 @@ 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)
# 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 `anyOf` 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; strip the `Bearer` prefix from the raw Authorization header value
- Bundle schemas: document `BundleEntry.resource` as a polymorphic Individual/Group body instead of an opaque dict, so bundle payloads are fully described in the OpenAPI document
- 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
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 # depends on individual + group for polymorphic_body refs
from . import membership
from . import operation_outcome
from . import patch
Expand Down
12 changes: 11 additions & 1 deletion spp_api_v2/schemas/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@

from pydantic import BaseModel, ConfigDict, Field

from odoo.addons.spp_api_v2.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 +55,12 @@ class BundleEntry(BaseModel):
)
request: BundleRequest | None = None
response: BundleResponse | None = None
resource: dict[str, Any] | None = None
resource: dict[str, Any] | None = polymorphic_body(
Individual,
Group,
default=None,
description="FHIR-style resource. Must match the type indicated by request.url.",
)
search: BundleSearch | None = None


Expand Down
39 changes: 30 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,39 @@ <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">anyOf</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; strip the <tt class="docutils literal">Bearer</tt> prefix from the
raw Authorization header value</li>
<li>Bundle schemas: document <tt class="docutils literal">BundleEntry.resource</tt> as a polymorphic
Individual/Group body instead of an opaque dict, so bundle payloads
are fully described in the OpenAPI document</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 +564,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
3 changes: 3 additions & 0 deletions spp_api_v2/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from . import test_audit_log_performance
from . import test_batch_api
from . import test_bulk_api
from . import test_bundle_openapi
from . import test_bundle_service
from . import test_consent
from . import test_consent_history
Expand All @@ -28,6 +29,8 @@
from . import test_jwt_secret_validation
from . import test_metadata
from . import test_oauth
from . import test_openapi_contract
from . import test_openapi_polymorphic
from . import test_organization_type_security
from . import test_pagination
from . import test_patch_api
Expand Down
47 changes: 47 additions & 0 deletions spp_api_v2/tests/test_bundle_openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""OpenAPI-shape tests for the Bundle schema.

Asserts that `BundleEntry.resource` documents its accepted resource types
(Individual, Group) via `oneOf` of $refs instead of a bare `dict | None`.
"""

from odoo.tests.common import HttpCase, tagged


@tagged("post_install", "-at_install")
class TestBundleEntryOpenAPI(HttpCase):
"""Bundle schema renders polymorphic resource documentation."""

def test_bundle_entry_resource_documented_as_oneof(self):
"""BundleEntry.resource should document oneOf of supported FHIR types.

Bundle service only supports Individual and Group (see
spp_api_v2/services/bundle_service.py:299, 324, 350); anything else
is rejected at runtime, so oneOf must list exactly those.
"""
response = self.url_open("/api/v2/spp/openapi.json")
self.assertEqual(response.status_code, 200, response.text)
schema = response.json()
components = schema["components"]["schemas"]

self.assertIn("BundleEntry", components)
resource_schema = components["BundleEntry"]["properties"]["resource"]

# Spike-confirmed shape: for `dict | None = polymorphic_body(...)`,
# Pydantic emits `anyOf: [{type: object}, {type: null}]` and our hook
# attaches `oneOf` at the SAME top level (siblings, not nested).
self.assertIn("oneOf", resource_schema, f"no oneOf at top level: {resource_schema}")
refs = [item.get("$ref") for item in resource_schema["oneOf"]]
self.assertIn("#/components/schemas/Individual", refs)
self.assertIn("#/components/schemas/Group", refs)

# And the nullable shape comes from anyOf alongside.
self.assertIn(
{"type": "null"},
resource_schema.get("anyOf", []),
f"missing nullable anyOf branch: {resource_schema}",
)

# Both referenced models must actually be present in components.
self.assertIn("Individual", components)
self.assertIn("Group", components)
45 changes: 45 additions & 0 deletions spp_api_v2/tests/test_openapi_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Contract test: every $ref in the OpenAPI schema must resolve.

Catches the failure mode where polymorphic_body declares a oneOf of $refs
but the referenced model isn't registered or the OpenAPI hook isn't
installed.
"""

from odoo.tests.common import HttpCase, tagged


@tagged("post_install", "-at_install")
class TestOpenAPIContract(HttpCase):
"""Walk the live OpenAPI schema; assert every $ref resolves."""

def test_all_refs_resolve(self):
response = self.url_open("/api/v2/spp/openapi.json")
self.assertEqual(response.status_code, 200, response.text)
schema = response.json()
components = schema.get("components", {}).get("schemas", {})

unresolved = []

def walk(node, path):
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
name = ref.rsplit("/", 1)[-1]
if name not in components:
unresolved.append((path, ref))
for k, v in node.items():
walk(v, f"{path}.{k}")
elif isinstance(node, list):
for i, v in enumerate(node):
walk(v, f"{path}[{i}]")

walk(schema, "$")

self.assertEqual(
unresolved,
[],
"Unresolved $refs in OpenAPI schema. Either install_polymorphic_openapi_hook "
"is not wired, or a polymorphic_body() references a model not in components/schemas.\n"
f"Found: {unresolved[:5]}",
)
Loading
Loading