diff --git a/pyproject.toml b/pyproject.toml index c61b786..b1bc96c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "httpx>=0.28", "itsdangerous>=2.2.0", "typer>=0.15", + "pydantic[email]>=2.12.5", ] [project.scripts] diff --git a/src/porchlight/validation.py b/src/porchlight/validation.py new file mode 100644 index 0000000..512ad02 --- /dev/null +++ b/src/porchlight/validation.py @@ -0,0 +1,42 @@ +from typing import Annotated +from urllib.parse import urlparse + +from pydantic import BaseModel, EmailStr, Field, field_validator +from pydantic_extra_types.phone_numbers import PhoneNumberValidator + +E164Phone = Annotated[str, PhoneNumberValidator(number_format="E164")] + + +class ProfileUpdate(BaseModel): + given_name: str = Field(default="", max_length=255) + family_name: str = Field(default="", max_length=255) + preferred_username: str = Field(default="", max_length=255) + email: EmailStr | None = None + phone_number: E164Phone | None = None + picture: str | None = Field(default=None, max_length=2048) + locale: str = Field(default="", max_length=20) + + @field_validator("email", mode="before") + @classmethod + def empty_email_to_none(cls, v: str) -> str | None: + if isinstance(v, str) and v.strip() == "": + return None + return v + + @field_validator("phone_number", mode="before") + @classmethod + def empty_phone_to_none(cls, v: str) -> str | None: + if isinstance(v, str) and v.strip() == "": + return None + return v + + @field_validator("picture", mode="before") + @classmethod + def validate_picture_url(cls, v: str) -> str | None: + if isinstance(v, str) and v.strip() == "": + return None + if isinstance(v, str): + parsed = urlparse(v) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError("Picture URL must be a valid HTTP or HTTPS URL") + return v diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..cdf3353 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,93 @@ +import pytest +from pydantic import ValidationError + +from porchlight.validation import ProfileUpdate + + +class TestProfileUpdateEmail: + def test_valid_email(self) -> None: + p = ProfileUpdate(email="user@example.com") + assert p.email == "user@example.com" + + def test_empty_email_becomes_none(self) -> None: + p = ProfileUpdate(email="") + assert p.email is None + + def test_invalid_email_no_at(self) -> None: + with pytest.raises(ValidationError, match="email"): + ProfileUpdate(email="not-an-email") + + def test_invalid_email_no_domain(self) -> None: + with pytest.raises(ValidationError, match="email"): + ProfileUpdate(email="user@") + + +class TestProfileUpdatePhone: + def test_valid_e164(self) -> None: + p = ProfileUpdate(phone_number="+46701234567") + assert p.phone_number == "+46701234567" + + def test_valid_e164_with_spaces_normalized(self) -> None: + p = ProfileUpdate(phone_number="+46 70 123 45 67") + assert p.phone_number == "+46701234567" + + def test_empty_phone_becomes_none(self) -> None: + p = ProfileUpdate(phone_number="") + assert p.phone_number is None + + def test_invalid_phone(self) -> None: + with pytest.raises(ValidationError, match="phone"): + ProfileUpdate(phone_number="not-a-phone") + + def test_invalid_phone_no_plus(self) -> None: + with pytest.raises(ValidationError, match="phone"): + ProfileUpdate(phone_number="46701234567") + + +class TestProfileUpdatePicture: + def test_valid_https_url(self) -> None: + p = ProfileUpdate(picture="https://example.com/pic.jpg") + assert p.picture == "https://example.com/pic.jpg" + + def test_valid_http_url(self) -> None: + p = ProfileUpdate(picture="http://example.com/pic.jpg") + assert p.picture == "http://example.com/pic.jpg" + + def test_empty_picture_becomes_none(self) -> None: + p = ProfileUpdate(picture="") + assert p.picture is None + + def test_invalid_picture_not_url(self) -> None: + with pytest.raises(ValidationError, match="picture"): + ProfileUpdate(picture="not-a-url") + + def test_invalid_picture_ftp_scheme(self) -> None: + with pytest.raises(ValidationError, match="picture"): + ProfileUpdate(picture="ftp://example.com/pic.jpg") + + +class TestProfileUpdateFieldLengths: + def test_given_name_too_long(self) -> None: + with pytest.raises(ValidationError, match="given_name"): + ProfileUpdate(given_name="x" * 256) + + def test_phone_number_max_length(self) -> None: + """E.164 max is 15 digits + plus sign = 16 chars.""" + p = ProfileUpdate(phone_number="+431234567890123") + assert p.phone_number == "+431234567890123" + + def test_locale_too_long(self) -> None: + with pytest.raises(ValidationError, match="locale"): + ProfileUpdate(locale="x" * 21) + + +class TestProfileUpdateDefaults: + def test_all_defaults(self) -> None: + p = ProfileUpdate() + assert p.given_name == "" + assert p.family_name == "" + assert p.preferred_username == "" + assert p.email is None + assert p.phone_number is None + assert p.picture is None + assert p.locale == "" diff --git a/uv.lock b/uv.lock index 9fe2dda..5c56af2 100644 --- a/uv.lock +++ b/uv.lock @@ -279,6 +279,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "fastapi" version = "0.128.8" @@ -558,6 +571,7 @@ dependencies = [ { name = "jinja2" }, { name = "motor" }, { name = "proquint" }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-extra-types", extra = ["phonenumbers"] }, { name = "pydantic-settings" }, { name = "python-multipart" }, @@ -585,6 +599,7 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1" }, { name = "motor", specifier = ">=3.7" }, { name = "proquint", specifier = ">=0.2" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.12.5" }, { name = "pydantic-extra-types", extras = ["phonenumbers"], specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.20" }, @@ -630,6 +645,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.41.5"