feat: add ProfileUpdate pydantic model with email and phone validation
This commit is contained in:
parent
428c17c4e3
commit
7c9e426bb8
4 changed files with 156 additions and 0 deletions
|
|
@ -20,6 +20,7 @@ dependencies = [
|
|||
"httpx>=0.28",
|
||||
"itsdangerous>=2.2.0",
|
||||
"typer>=0.15",
|
||||
"pydantic[email]>=2.12.5",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
42
src/porchlight/validation.py
Normal file
42
src/porchlight/validation.py
Normal file
|
|
@ -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
|
||||
93
tests/test_validation.py
Normal file
93
tests/test_validation.py
Normal file
|
|
@ -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 == ""
|
||||
20
uv.lock
generated
20
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue