Skip to content

Commit aa7a31f

Browse files
feat(spp_api_v2): re-land OpenAPI polymorphic utils, auth middleware, bundle schemas (from #76)
Re-lands the spp_api_v2 portion of PR #76, which was reverted wholesale in d38ff9d. Restores the OpenAPI polymorphic schema utilities and app hook, the OAuth2 client-credentials security scheme in the auth middleware, the polymorphic BundleEntry.resource schema, and the OpenAPI contract tests, exactly as merged in 8bf9a3a. Bumps the module version to 19.0.2.1.0 with a matching HISTORY entry.
1 parent 47b12f1 commit aa7a31f

11 files changed

Lines changed: 478 additions & 12 deletions

File tree

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: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,32 @@
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+
# OAuth2 Client Credentials scheme.
18+
# This produces an "oauth2" entry in the OpenAPI securitySchemes with the
19+
# clientCredentials flow pointing at our token endpoint, so API consumers
20+
# (Swagger UI, QGIS, etc.) can discover how to authenticate.
21+
# auto_error=False allows us to handle authentication errors with proper status codes.
22+
security = OAuth2(
23+
flows={
24+
"clientCredentials": {
25+
"tokenUrl": "oauth/token",
26+
"scopes": {},
27+
},
28+
},
29+
auto_error=False,
30+
)
2031

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

2536

2637
def get_authenticated_client(
27-
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
38+
token: Annotated[str | None, Depends(security)],
2839
env: Annotated[Environment, Depends(odoo_env)],
2940
):
3041
"""
@@ -39,14 +50,17 @@ def get_authenticated_client(
3950
Raises:
4051
HTTPException: If token is invalid, expired, or client not found
4152
"""
42-
if not credentials:
53+
if not token:
4354
raise HTTPException(
4455
status_code=status.HTTP_401_UNAUTHORIZED,
4556
detail="Missing Authorization header",
4657
headers={"WWW-Authenticate": "Bearer"},
4758
)
4859

49-
token = credentials.credentials
60+
# OAuth2 dependency returns the full Authorization header value
61+
# (e.g. "Bearer eyJ..."). Strip the scheme prefix to get the raw JWT.
62+
if token.lower().startswith("bearer "):
63+
token = token[7:]
5064

5165
try:
5266
# Decode and validate JWT
@@ -91,7 +105,7 @@ def get_authenticated_client(
91105

92106

93107
def get_current_client(
94-
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
108+
token: Annotated[str | None, Depends(security)],
95109
env: Annotated[Environment, Depends(odoo_env)],
96110
) -> dict:
97111
"""
@@ -103,7 +117,7 @@ def get_current_client(
103117
Returns:
104118
dict: {"env": Environment, "client": spp.api.client record}
105119
"""
106-
client = get_authenticated_client(credentials, env)
120+
client = get_authenticated_client(token, env)
107121
return {"env": env, "client": client}
108122

109123

spp_api_v2/models/fastapi_endpoint_registry.py

Lines changed: 4 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,8 @@ 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)
8286
# V2 API uses public endpoint with JWT authentication in middleware
8387
# No default authentication required at app level
8488
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 `anyOf` 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; strip the `Bearer` prefix from the raw Authorization header value
5+
- 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
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/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 # depends on individual + group for polymorphic_body refs
88
from . import membership
99
from . import operation_outcome
1010
from . import patch

spp_api_v2/schemas/bundle.py

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

66
from pydantic import BaseModel, ConfigDict, Field
77

8+
from odoo.addons.spp_api_v2.utils.openapi_polymorphic import polymorphic_body
9+
10+
from .group import Group
11+
from .individual import Individual
12+
813

914
class BundleLink(BaseModel):
1015
"""Link in a bundle (for pagination)"""
@@ -50,7 +55,12 @@ class BundleEntry(BaseModel):
5055
)
5156
request: BundleRequest | None = None
5257
response: BundleResponse | None = None
53-
resource: dict[str, Any] | None = None
58+
resource: dict[str, Any] | None = polymorphic_body(
59+
Individual,
60+
Group,
61+
default=None,
62+
description="FHIR-style resource. Must match the type indicated by request.url.",
63+
)
5464
search: BundleSearch | None = None
5565

5666

spp_api_v2/tests/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from . import test_audit_log_performance
1313
from . import test_batch_api
1414
from . import test_bulk_api
15+
from . import test_bundle_openapi
1516
from . import test_bundle_service
1617
from . import test_consent
1718
from . import test_consent_history
@@ -28,6 +29,8 @@
2829
from . import test_jwt_secret_validation
2930
from . import test_metadata
3031
from . import test_oauth
32+
from . import test_openapi_contract
33+
from . import test_openapi_polymorphic
3134
from . import test_organization_type_security
3235
from . import test_pagination
3336
from . import test_patch_api
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""OpenAPI-shape tests for the Bundle schema.
3+
4+
Asserts that `BundleEntry.resource` documents its accepted resource types
5+
(Individual, Group) via `oneOf` of $refs instead of a bare `dict | None`.
6+
"""
7+
8+
from odoo.tests.common import HttpCase, tagged
9+
10+
11+
@tagged("post_install", "-at_install")
12+
class TestBundleEntryOpenAPI(HttpCase):
13+
"""Bundle schema renders polymorphic resource documentation."""
14+
15+
def test_bundle_entry_resource_documented_as_oneof(self):
16+
"""BundleEntry.resource should document oneOf of supported FHIR types.
17+
18+
Bundle service only supports Individual and Group (see
19+
spp_api_v2/services/bundle_service.py:299, 324, 350); anything else
20+
is rejected at runtime, so oneOf must list exactly those.
21+
"""
22+
response = self.url_open("/api/v2/spp/openapi.json")
23+
self.assertEqual(response.status_code, 200, response.text)
24+
schema = response.json()
25+
components = schema["components"]["schemas"]
26+
27+
self.assertIn("BundleEntry", components)
28+
resource_schema = components["BundleEntry"]["properties"]["resource"]
29+
30+
# Spike-confirmed shape: for `dict | None = polymorphic_body(...)`,
31+
# Pydantic emits `anyOf: [{type: object}, {type: null}]` and our hook
32+
# attaches `oneOf` at the SAME top level (siblings, not nested).
33+
self.assertIn("oneOf", resource_schema, f"no oneOf at top level: {resource_schema}")
34+
refs = [item.get("$ref") for item in resource_schema["oneOf"]]
35+
self.assertIn("#/components/schemas/Individual", refs)
36+
self.assertIn("#/components/schemas/Group", refs)
37+
38+
# And the nullable shape comes from anyOf alongside.
39+
self.assertIn(
40+
{"type": "null"},
41+
resource_schema.get("anyOf", []),
42+
f"missing nullable anyOf branch: {resource_schema}",
43+
)
44+
45+
# Both referenced models must actually be present in components.
46+
self.assertIn("Individual", components)
47+
self.assertIn("Group", components)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Contract test: every $ref in the OpenAPI schema must resolve.
3+
4+
Catches the failure mode where polymorphic_body declares a oneOf of $refs
5+
but the referenced model isn't registered or the OpenAPI hook isn't
6+
installed.
7+
"""
8+
9+
from odoo.tests.common import HttpCase, tagged
10+
11+
12+
@tagged("post_install", "-at_install")
13+
class TestOpenAPIContract(HttpCase):
14+
"""Walk the live OpenAPI schema; assert every $ref resolves."""
15+
16+
def test_all_refs_resolve(self):
17+
response = self.url_open("/api/v2/spp/openapi.json")
18+
self.assertEqual(response.status_code, 200, response.text)
19+
schema = response.json()
20+
components = schema.get("components", {}).get("schemas", {})
21+
22+
unresolved = []
23+
24+
def walk(node, path):
25+
if isinstance(node, dict):
26+
ref = node.get("$ref")
27+
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
28+
name = ref.rsplit("/", 1)[-1]
29+
if name not in components:
30+
unresolved.append((path, ref))
31+
for k, v in node.items():
32+
walk(v, f"{path}.{k}")
33+
elif isinstance(node, list):
34+
for i, v in enumerate(node):
35+
walk(v, f"{path}[{i}]")
36+
37+
walk(schema, "$")
38+
39+
self.assertEqual(
40+
unresolved,
41+
[],
42+
"Unresolved $refs in OpenAPI schema. Either install_polymorphic_openapi_hook "
43+
"is not wired, or a polymorphic_body() references a model not in components/schemas.\n"
44+
f"Found: {unresolved[:5]}",
45+
)

0 commit comments

Comments
 (0)