-
Notifications
You must be signed in to change notification settings - Fork 14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
#299-feature/presentation submission dynamic handler #314
base: refac
Are you sure you want to change the base?
Changes from all commits
52682cd
df31213
1eb207b
35d23c8
68a81d5
384813a
6ae5b6c
6b4a1f9
72fe694
6c6a456
345006e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
formats: | ||
- name: "dc+sd-jwt" | ||
module: "pyeudiw.openid4vp.presentation_submission" | ||
class: "VcSdJwt" | ||
- name: "ldp_vp" | ||
module: "pyeudiw.openid4vp.presentation_submission" | ||
class: "LdpVp" | ||
- name: "jwt_vp_json" | ||
module: "pyeudiw.openid4vp.presentation_submission" | ||
class: "JwtVpJson" | ||
- name: "ac_vp" | ||
module: "pyeudiw.openid4vp.presentation_submission" | ||
class: "AcVp" | ||
|
||
MAX_SUBMISSION_SIZE: 4096 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import os | ||
from pydantic import ValidationError | ||
import yaml | ||
import importlib | ||
from typing import Dict, Any | ||
import logging | ||
|
||
from pyeudiw.openid4vp.presentation_submission.schemas import PresentationSubmissionSchema | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
class PresentationSubmission: | ||
def __init__(self, submission: Dict[str, Any]): | ||
""" | ||
Initialize the PresentationSubmission handler with the submission data. | ||
|
||
Args: | ||
submission (Dict[str, Any]): The presentation submission data. | ||
|
||
Raises: | ||
KeyError: If the 'format' key is missing in the submission. | ||
ValueError: If the format is not supported or not defined in the configuration. | ||
ImportError: If the module or class cannot be loaded. | ||
ValidationError: If the submission data is invalid or exceeds size limits. | ||
""" | ||
self.config = self._load_config() | ||
self.submission = self._validate_submission(submission) | ||
self.handlers = self._initialize_handlers() | ||
|
||
def _load_config(self) -> Dict[str, Any]: | ||
""" | ||
Load the configuration from format_config.yml located in the same directory. | ||
|
||
Returns: | ||
Dict[str, Any]: The configuration dictionary. | ||
|
||
Raises: | ||
FileNotFoundError: If the configuration file is not found. | ||
""" | ||
config_path = os.path.join(os.path.dirname(__file__), "config.yml") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the point of having a class configurable by editing an obscure configuration file in the installed class project that is available who-knows-where based on what @peppelinux @LadyCodesItBetter I'm not sure here what is the intended design goal. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the intended propose is for a generic python package, not necessarly used in the iam proxy context There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The point still stands. Assume I am using the project as a package, that is, I am using it with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, absolutely. We cannot rely on the relative path of tha file distributed within a python package. |
||
if not os.path.exists(config_path): | ||
raise FileNotFoundError(f"Configuration file not found: {config_path}") | ||
|
||
with open(config_path, "r") as config_file: | ||
return yaml.safe_load(config_file) | ||
|
||
def _validate_submission(self, submission: Dict[str, Any]) -> PresentationSubmissionSchema: | ||
""" | ||
Validate the submission data using Pydantic and check its total size. | ||
|
||
Args: | ||
submission (Dict[str, Any]): The presentation submission data. | ||
|
||
Returns: | ||
PresentationSubmissionSchema: Validated submission schema. | ||
|
||
Raises: | ||
ValidationError: If the submission data is invalid or exceeds size limits. | ||
""" | ||
max_size = self.config.get("MAX_SUBMISSION_SIZE", 10 * 1024 * 1024) | ||
|
||
# Check submission size | ||
submission_size = len(str(submission).encode("utf-8")) | ||
if submission_size > max_size: | ||
logger.warning( | ||
f"Rejected submission: size {submission_size} bytes exceeds limit {max_size} bytes." | ||
) | ||
raise ValueError( | ||
f"Submission size exceeds maximum allowed limit of {max_size} bytes." | ||
) | ||
|
||
try: | ||
return PresentationSubmissionSchema(**submission) | ||
except ValidationError as e: | ||
logger.error(f"Submission validation failed: {e}") | ||
raise | ||
peppelinux marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
def _initialize_handlers(self) -> Dict[int, object]: | ||
""" | ||
Initialize handlers for each item in the 'descriptor_map' of the submission. | ||
|
||
Returns: | ||
Dict[int, object]: A dictionary mapping indices to handler instances. | ||
|
||
Raises: | ||
KeyError: If the 'format' key is missing in any descriptor. | ||
ValueError: If a format is not supported or not defined in the configuration. | ||
ImportError: If a module or class cannot be loaded. | ||
""" | ||
handlers = {} | ||
|
||
try: | ||
descriptor_map = self.submission.descriptor_map | ||
except KeyError: | ||
raise KeyError("The 'descriptor_map' key is missing in the submission.") | ||
|
||
for index, descriptor in enumerate(descriptor_map): | ||
format_name = descriptor.format | ||
if not format_name: | ||
raise KeyError(f"The 'format' key is missing in descriptor at index {index}.") | ||
|
||
# Search for the format in the configuration | ||
format_conf = next((fmt for fmt in self.config.get("formats", []) if fmt["name"] == format_name), None) | ||
if not format_conf: | ||
raise ValueError(f"Format '{format_name}' is not supported or not defined in the configuration.") | ||
|
||
module_name = format_conf["module"] | ||
class_name = format_conf["class"] | ||
|
||
try: | ||
# Dynamically load the module and class | ||
module = importlib.import_module(module_name) | ||
cls = getattr(module, class_name) | ||
handlers[index] = cls() # Instantiate the class | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AFAIK the fact that each handler is a class initialized with an empty constructor implies that handlers are functionally just namespace for static methods. It's impossible to known what they should do without doing introspection on what aforementioned namespace contains. |
||
except ModuleNotFoundError: | ||
logger.warning(f"Module '{module_name}' not found for format '{format_name}'. Skipping index {index}.") | ||
except AttributeError: | ||
logger.warning(f"Class '{class_name}' not found in module '{module_name}' for format '{format_name}'. Skipping index {index}.") | ||
except Exception as e: | ||
logger.warning(f"Error loading format '{format_name}' for index {index}: {e}") | ||
|
||
return handlers |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
from typing import Any, Dict, List | ||
from pydantic import BaseModel, field_validator | ||
|
||
|
||
class DescriptorSchema(BaseModel): | ||
id: str | ||
format: str | ||
path: str | ||
path_nested: Dict[str, Any] = None | ||
|
||
|
||
class PresentationSubmissionSchema(BaseModel): | ||
id: str | ||
definition_id: str | ||
descriptor_map: List[DescriptorSchema] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. go ahead using list without any import |
||
|
||
@field_validator("descriptor_map") | ||
@classmethod | ||
def check_descriptor_map_size(cls, value): | ||
max_descriptors = 100 # TODO: Define a reasonable limit | ||
if len(value) > max_descriptors: | ||
raise ValueError(f"descriptor_map exceeds maximum allowed size of {max_descriptors} items.") | ||
return value |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import pytest | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe I'm wrong but if a test file name does not start with In doubt, this file should be named There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can confirm that _test doesn't get loaded |
||
from unittest.mock import patch, MagicMock | ||
from pydantic import ValidationError | ||
from pyeudiw.openid4vp.presentation_submission.presentation_submission import PresentationSubmission | ||
|
||
|
||
# Mock data for testing | ||
mock_format_config = { | ||
"formats": [ | ||
{"name": "ldp_vp", "module": "mock.module", "class": "MockLdpVpHandler"}, | ||
{"name": "jwt_vp_json", "module": "mock.module", "class": "MockJwtVpJsonHandler"} | ||
], | ||
"MAX_SUBMISSION_SIZE": 10 * 1024 # 10 KB | ||
} | ||
|
||
valid_submission = { | ||
"id": "submission_id", | ||
"definition_id": "definition_id", | ||
"descriptor_map": [ | ||
{"id": "descriptor_1", "format": "ldp_vp", "path": "$"}, | ||
{"id": "descriptor_2", "format": "jwt_vp_json", "path": "$"} | ||
] | ||
} | ||
|
||
large_submission = { | ||
"id": "submission_id_large", | ||
"definition_id": "definition_id_large", | ||
"descriptor_map": [{"id": f"descriptor_{i}", "format": "ldp_vp", "path": "$"} for i in range(101)] # Exceeds limit | ||
} | ||
|
||
|
||
def test_presentation_submission_initialization_with_schema_validation(): | ||
""" | ||
Test that the PresentationSubmission class initializes correctly | ||
and validates against the Pydantic schema. | ||
""" | ||
# Mock handler classes | ||
mock_ldp_vp_handler = MagicMock(name="MockLdpVpHandler") | ||
mock_jwt_vp_json_handler = MagicMock(name="MockJwtVpJsonHandler") | ||
|
||
# Mock import_module to return a fake module with our mock classes | ||
mock_module = MagicMock() | ||
setattr(mock_module, "MockLdpVpHandler", mock_ldp_vp_handler) | ||
setattr(mock_module, "MockJwtVpJsonHandler", mock_jwt_vp_json_handler) | ||
|
||
with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config), \ | ||
patch("importlib.import_module", return_value=mock_module): | ||
|
||
# Initialize the class | ||
ps = PresentationSubmission(valid_submission) | ||
|
||
# Assert that handlers were created for all formats in descriptor_map | ||
assert len(ps.handlers) == len(valid_submission["descriptor_map"]), "Not all handlers were created." | ||
|
||
# Check that the handlers are instances of the mocked classes | ||
assert ps.handlers[0] is mock_ldp_vp_handler(), "Handler for 'ldp_vp' format is incorrect." | ||
assert ps.handlers[1] is mock_jwt_vp_json_handler(), "Handler for 'jwt_vp_json' format is incorrect." | ||
|
||
|
||
def test_presentation_submission_large_submission_with_schema(): | ||
""" | ||
Test that the PresentationSubmission class raises a ValidationError | ||
when the submission exceeds the descriptor_map size limit. | ||
""" | ||
with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config): | ||
# Expect a ValidationError for exceeding descriptor_map size limit | ||
with pytest.raises(ValidationError, match="descriptor_map exceeds maximum allowed size of 100 items"): | ||
PresentationSubmission(large_submission) | ||
|
||
|
||
def test_presentation_submission_missing_descriptor_key(): | ||
""" | ||
Test that the PresentationSubmission class raises a ValidationError | ||
when required keys are missing in the descriptor_map. | ||
""" | ||
invalid_submission = { | ||
"id": "invalid_submission_id", | ||
"definition_id": "invalid_definition_id", | ||
"descriptor_map": [ | ||
{"format": "ldp_vp"} | ||
] | ||
} | ||
|
||
with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config): | ||
|
||
with pytest.raises(ValidationError, match=r"Field required"): | ||
PresentationSubmission(invalid_submission) | ||
|
||
def test_presentation_submission_invalid_format(): | ||
""" | ||
Test that the PresentationSubmission class raises a ValueError | ||
when an unsupported format is encountered. | ||
""" | ||
invalid_submission = { | ||
"id": "invalid_submission_id", | ||
"definition_id": "invalid_definition_id", | ||
"descriptor_map": [ | ||
{"format": "unsupported_format", "id": "descriptor_1", "path": "$"} | ||
] | ||
} | ||
|
||
with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config): | ||
with pytest.raises(ValueError, match="Format 'unsupported_format' is not supported or not defined in the configuration."): | ||
PresentationSubmission(invalid_submission) | ||
|
||
def test_presentation_submission_missing_format_key(): | ||
""" | ||
Test that the PresentationSubmission class raises a KeyError | ||
when the 'format' key is missing in a descriptor. | ||
""" | ||
missing_format_key_submission = { | ||
"id": "missing_format_submission_id", | ||
"definition_id": "missing_format_definition_id", | ||
"descriptor_map": [ | ||
{"id": "descriptor_1", "path": "$"} # Missing 'format' key | ||
] | ||
} | ||
|
||
with patch("pyeudiw.openid4vp.presentation_submission.presentation_submission.PresentationSubmission._load_config", return_value=mock_format_config): | ||
with pytest.raises(ValidationError, match=r"descriptor_map\.0\.format\s+Field required"): | ||
PresentationSubmission(missing_format_key_submission) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Importing
Dict
is deprecated since Python 3.9 and this project officially supports Python 3.10+ only - usedict
instead (no import required).https://docs.python.org/3/library/typing.html#aliases-to-built-in-types
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
go ahead using dict without any import