diff --git a/sefaria/model/abstract.py b/sefaria/model/abstract.py index ad2d0ddc66..723baf1ace 100644 --- a/sefaria/model/abstract.py +++ b/sefaria/model/abstract.py @@ -637,3 +637,71 @@ def foo(obj, **kwargs): rec.save() return foo + +class SchemaValidationException(Exception): + def __init__(self, key, expected_type): + self.key = key + self.expected_type = expected_type + self.message = f"Invalid value for key '{key}'. Expected type: {expected_type}" + super().__init__(self.message) +class SchemaRequiredFieldException(Exception): + def __init__(self, key): + self.key = key + self.message = f"Required field '{key}' is missing." + super().__init__(self.message) + +class SchemaInvalidKeyException(Exception): + def __init__(self, key): + self.key = key + self.message = f"Invalid key '{key}' found in data dictionary." + super().__init__(self.message) + + +def validate_dictionary(data, schema): + """ + Validates that a given dictionary complies with the provided schema. + + Args: + data (dict): The dictionary to be validated. + schema (dict): The schema dictionary specifying the expected structure. + + Raises: + SchemaValidationException: If the data does not comply with the schema. + + Returns: + bool: True if the data complies with the schema, False otherwise. + """ + + for key, value_type in schema.items(): + if not (isinstance(value_type, tuple) and len(value_type) == 2 and value_type[1] in ["optional", "required"]): + raise ValueError(f"Invalid schema definition for key '{key}'. Use ('type', 'optional') or ('type', 'required').") + + # Check for keys in data that are not in schema + for key in data.keys(): + if key not in schema: + raise SchemaInvalidKeyException(key) + + for key, value_type in schema.items(): + # Check if the key exists in the data dictionary + if key not in data: + # Check if the field is optional (not required) + if isinstance(value_type, tuple) and len(value_type) == 2 and value_type[1] == "optional": + continue # Field is optional, so skip validation + else: + raise SchemaRequiredFieldException(key) + + # Check if the expected type is a nested dictionary + if isinstance(value_type[0], dict): + nested_data = data[key] + nested_schema = value_type[0] + try: + # Recursively validate the nested dictionary + validate_dictionary(nested_data, nested_schema) + except SchemaValidationException as e: + # If validation fails for the nested dictionary, re-raise the exception with the key + raise SchemaValidationException(f"{key}.{e.key}", e.expected_type) + + # Check the type of the value in the data dictionary + elif not isinstance(data[key], value_type[0]): + raise SchemaValidationException(key, value_type[0]) + return True diff --git a/sefaria/model/portal.py b/sefaria/model/portal.py new file mode 100644 index 0000000000..ba39e96d45 --- /dev/null +++ b/sefaria/model/portal.py @@ -0,0 +1,135 @@ +# coding=utf-8 +from urllib.parse import urlparse +from . import abstract as abst +import urllib.parse +import structlog +logger = structlog.get_logger(__name__) + +def get_nested_value(data, key): + """ + Get the value of a key in a dictionary or nested dictionaries. + + Args: + data (dict): The dictionary to search. + key (str): The key to retrieve the value for. + + Returns: + The value associated with the key, or None if the key is not found. + """ + if key in data: + return data[key] + + for value in data.values(): + if isinstance(value, dict): + nested_value = get_nested_value(value, key) + if nested_value is not None: + return nested_value + + return None +class InvalidURLException(Exception): + def __init__(self, url): + self.url = url + self.message = f"'{url}' is not a valid URL." + super().__init__(self.message) + +def validate_url(url): + try: + # Attempt to parse the URL + result = urllib.parse.urlparse(url) + + # Check if the scheme (e.g., http, https) and netloc (e.g., domain) are present + if result.scheme and result.netloc: + return True + else: + raise InvalidURLException(url) + except ValueError: + # URL parsing failed + raise InvalidURLException(url) +class InvalidHTTPMethodException(Exception): + def __init__(self, method): + self.method = method + self.message = f"'{method}' is not a valid HTTP API method." + super().__init__(self.message) + +def validate_http_method(method): + """ + Validate if a string represents a valid HTTP API method. + + Args: + method (str): The HTTP method to validate. + + Raises: + InvalidHTTPMethodException: If the method is not valid. + + Returns: + bool: True if the method is valid, False otherwise. + """ + valid_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] + + # Convert the method to uppercase and check if it's in the list of valid methods + if method.upper() in valid_methods: + return True + else: + raise InvalidHTTPMethodException(method) + +class Portal(abst.AbstractMongoRecord): + collection = 'portals' + + required_attrs = [ + "about", + ] + optional_attrs = [ + "mobile", + 'api_schema', + ] + + def _validate(self): + super(Portal, self)._validate() + + about_schema = { + "title": ({"en": (str, "required"), "he": (str, "required")}, "required"), + "title_url": (str, "optional"), + "image_uri": (str, "optional"), + "description": ({"en": (str, "required"), "he": (str, "required")}, "optional"), + } + + mobile_schema = { + "title": ({"en": (str, "required"), "he": (str, "required")}, "required"), + "android_link": (str, "optional"), + "ios_link": (str, "optional") + } + + newsletter_schema = { + "title": ({"en": (str, "required"), "he": (str, "required")}, "required"), + "title_url": (str, "optional"), + "description": ({"en": (str, "required"), "he": (str, "required")}, "optional"), + "api_schema": ({"http_method": (str, "required"), + "payload": ({"first_name_key": (str, "optional"), "last_name_key": (str, "optional"), "email_key": (str, "optional")}, "optional")} + , "optional") + } + + if hasattr(self, "about"): + abst.validate_dictionary(self.about, about_schema) + title_url = get_nested_value(self.about, "title_url") + if title_url: + validate_url(title_url) + if hasattr(self, "mobile"): + abst.validate_dictionary(self.mobile, mobile_schema) + android_link = get_nested_value(self.mobile,"android_link") + if android_link: + validate_url(android_link) + ios_link = get_nested_value(self.mobile, "ios_link") + if ios_link: + validate_url(ios_link) + if hasattr(self, "newsletter"): + abst.validate_dictionary(self.newsletter, newsletter_schema) + http_method = get_nested_value(self.newsletter, "http_method") + if http_method: + validate_http_method(http_method) + return True + + + + + + diff --git a/sefaria/model/tests/portal_test.py b/sefaria/model/tests/portal_test.py new file mode 100644 index 0000000000..4f7209363a --- /dev/null +++ b/sefaria/model/tests/portal_test.py @@ -0,0 +1,409 @@ +import pytest +from sefaria.model.portal import Portal # Replace with your actual validation function + +valids = [ + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "https://example.com", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "title_url": "https://newsletter-url.com", + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + } + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "GET" + } + } + }, +{ + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "https://example.com", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "title_url": "https://newsletter-url.com", + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + } + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "GET" + } + } + }, + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + } + } + }, + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "https://example.com", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "title_url": "https://newsletter-url.com", + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + } + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "GET" + } + } + }, + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "image_uri": "gs://your-bucket/image.jpg" + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + } + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + } +] + +invalids = [ + # Missing "about" key + { + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + # Invalid "about.title_url" (not a URL) + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "invalid-url", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "title_url": "https://newsletter-url.com", + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + # Missing required "mobile.ios_link" + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "https://example.com", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "title_url": "https://newsletter-url.com", + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "POST", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + }, + # Invalid "newsletter.api_schema.http_method" (not a valid HTTP method) + { + "about": { + "title": { + "en": "English Title", + "he": "Hebrew Title" + }, + "title_url": "https://example.com", + "image_uri": "gs://your-bucket/image.jpg", + "description": { + "en": "English Description", + "he": "Hebrew Description" + } + }, + "mobile": { + "title": { + "en": "Mobile Title", + "he": "Mobile Hebrew Title" + }, + "android_link": "https://android-link.com", + "ios_link": "https://ios-link.com" + }, + "newsletter": { + "title": { + "en": "Newsletter Title", + "he": "Newsletter Hebrew Title" + }, + "title_url": "https://newsletter-url.com", + "description": { + "en": "Newsletter English Description", + "he": "Newsletter Hebrew Description" + }, + "api_schema": { + "http_method": "INVALID_METHOD", + "payload": { + "first_name_key": "fname", + "last_name_key": "lname", + "email_key": "email" + } + } + } + } +] +def test_valid_schema(): + for case in valids: + p = Portal(case) + assert p._validate() == True +