Skip to content
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

Domain name string type #212

Merged
merged 9 commits into from
Sep 6, 2024
50 changes: 50 additions & 0 deletions pydantic_extra_types/domain.py
yezz123 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
The `domain_str` module provides the `DomainStr` data type.
This class depends on the `pydantic` package and implements custom validation for domain string format.
"""

from __future__ import annotations

import re
from typing import Any, Generator, Mapping

from pydantic import GetCoreSchemaHandler
from pydantic_core import PydanticCustomError, core_schema


class DomainStr(str):
"""
A string subclass with custom validation for domain string format.
"""

@classmethod
def __get_validators__(cls) -> Generator[Any]:
yield cls.validate # pragma: no cover

@classmethod
def validate(cls, v: Any) -> DomainStr:
if not isinstance(v, str):
raise PydanticCustomError('domain_type', 'Value must be a string') # pragma: no cover

v = v.strip().lower()
if len(v) < 1 or len(v) > 253:
raise PydanticCustomError('domain_length', 'Domain must be between 1 and 253 characters')

pattern = r'^([a-z0-9-]+(\.[a-z0-9-]+)+)$'
if not re.match(pattern, v):
raise PydanticCustomError('domain_format', 'Invalid domain format')

return cls(v)

@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
return core_schema.no_info_after_validator_function(
cls.validate,
core_schema.str_schema(),
)

@classmethod
def __get_pydantic_json_schema__(
cls, schema: core_schema.CoreSchema, handler: GetCoreSchemaHandler
) -> Mapping[str, Any]:
return handler(schema) # pragma: no cover
yezz123 marked this conversation as resolved.
Show resolved Hide resolved
82 changes: 82 additions & 0 deletions tests/test_domain.py
yezz123 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from typing import Any

import pytest
from pydantic import BaseModel, ValidationError

from pydantic_extra_types.domain import DomainStr


class MyModel(BaseModel):
domain: DomainStr


valid_domains = [
'example.com',
'sub.example.com',
'sub-domain.example-site.co.uk',
'a.com',
'x.com',
'1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.com', # Multiple subdomains
]

invalid_domains = [
'', # Empty string
'example', # Missing TLD
'.com', # Missing domain name
'example.', # Trailing dot
'exam ple.com', # Space in domain
'exa_mple.com', # Underscore in domain
'example.com.', # Trailing dot
]

very_long_domains = [
'a' * 249 + '.com', # Just under the limit
'a' * 250 + '.com', # At the limit
'a' * 251 + '.com', # Just over the limit
'sub1.sub2.sub3.sub4.sub5.sub6.sub7.sub8.sub9.sub10.sub11.sub12.sub13.sub14.sub15.sub16.sub17.sub18.sub19.sub20.sub21.sub22.sub23.sub24.sub25.sub26.sub27.sub28.sub29.sub30.sub31.sub32.sub33.extremely-long-domain-name-example-to-test-the-253-character-limit.com',
]

invalid_domain_types = [1, 2, 1.1, 2.1, False, [], {}, None]


@pytest.mark.parametrize('domain', valid_domains)
def test_valid_domains(domain: str):
try:
MyModel.model_validate({'domain': domain})
assert len(domain) < 254 and len(domain) > 0
except ValidationError:
assert len(domain) > 254 or len(domain) == 0


@pytest.mark.parametrize('domain', invalid_domains)
def test_invalid_domains(domain: str):
try:
MyModel.model_validate({'domain': domain})
raise Exception(
f"This test case has only samples that should raise a ValidationError. This domain '{domain}' did not raise such an exception."
)
except ValidationError:
# An error is expected on this test
pass


@pytest.mark.parametrize('domain', very_long_domains)
def test_very_long_domains(domain: str):
try:
MyModel.model_validate({'domain': domain})
assert len(domain) < 254 and len(domain) > 0
except ValidationError:
# An error is expected on this test
pass


@pytest.mark.parametrize('domain', invalid_domain_types)
def test_invalid_domain_types(domain: Any):
try:
MyModel.model_validate({'domain': domain})
raise Exception(
f"This test case types that should raise the ValidationError exception. This value '{domain}' did not raise such an exception."
)
except ValidationError:
# An error is expected on this test
pass
31 changes: 19 additions & 12 deletions tests/test_json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,11 @@
import pydantic_extra_types
from pydantic_extra_types.color import Color
from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude
from pydantic_extra_types.country import (
CountryAlpha2,
CountryAlpha3,
CountryNumericCode,
CountryShortName,
)
from pydantic_extra_types.country import CountryAlpha2, CountryAlpha3, CountryNumericCode, CountryShortName
from pydantic_extra_types.currency_code import ISO4217, Currency
from pydantic_extra_types.domain import DomainStr
from pydantic_extra_types.isbn import ISBN
from pydantic_extra_types.language_code import (
ISO639_3,
ISO639_5,
LanguageAlpha2,
LanguageName,
)
from pydantic_extra_types.language_code import ISO639_3, ISO639_5, LanguageAlpha2, LanguageName
from pydantic_extra_types.mac_address import MacAddress
from pydantic_extra_types.payment import PaymentCardNumber
from pydantic_extra_types.pendulum_dt import DateTime
Expand Down Expand Up @@ -451,6 +442,22 @@
],
},
),
(
DomainStr,
{
'title': 'Model',
'type': 'object',
'properties': {
'x': {
'title': 'X',
'type': 'string',
},
},
'required': [
'x',
],
},
),
],
)
def test_json_schema(cls, expected):
Expand Down
Loading