Skip to content

Commit 1953a0e

Browse files
committed
✨ Add (module): Pydantic schemas representation for API requests and responses
1 parent aea2f0e commit 1953a0e

File tree

4 files changed

+179
-8
lines changed

4 files changed

+179
-8
lines changed

app/config/exceptions.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,14 @@ def __init__(self, message: str, note: str | None = None):
1414
super().__init__(message)
1515
if note:
1616
self.add_note(note)
17+
18+
19+
class ServiceException(Exception): # type: ignore
20+
"""
21+
Service Layer Exception class
22+
"""
23+
24+
def __init__(self, message: str, note: str | None = None):
25+
super().__init__(message)
26+
if note:
27+
self.add_note(note)

app/config/utils.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22
A module for openapi utils in the app.config package.
33
"""
44

5+
import re
56
from typing import Any
67

8+
import phonenumbers
79
from fastapi import FastAPI
810
from fastapi.openapi.utils import get_openapi
911
from fastapi.routing import APIRoute
12+
from pydantic_extra_types.phone_numbers import PhoneNumber
13+
14+
from app.config.exceptions import ServiceException
15+
from app.config.settings import setting
1016

1117

1218
def remove_tag_from_operation_id(tag: str, operation_id: str) -> str:
@@ -107,3 +113,45 @@ def custom_openapi(app: FastAPI) -> dict[str, Any]:
107113
openapi_schema = modify_json_data(openapi_schema)
108114
app.openapi_schema = openapi_schema
109115
return app.openapi_schema
116+
117+
118+
def validate_password(password: str | None) -> str:
119+
"""
120+
Validates a password based on given criteria.
121+
:param password: The password to validate.
122+
:type password: Optional[str]
123+
:return: The validated password.
124+
:rtype: str
125+
"""
126+
if not password:
127+
raise ServiceException("Password cannot be empty or None")
128+
if not (
129+
re.search("[A-Z]", password)
130+
and re.search("[a-z]", password)
131+
and re.search("[0-9]", password)
132+
and re.search(setting.DB_USER_PASSWORD_CONSTRAINT, password)
133+
and 8 <= len(password) <= 14
134+
):
135+
raise ValueError("Password validation failed")
136+
return password
137+
138+
139+
def validate_phone_number(
140+
phone_number: PhoneNumber | None,
141+
) -> PhoneNumber | None:
142+
"""
143+
Validate the phone number format
144+
:param phone_number: The phone number to validate
145+
:type phone_number: Optional[PhoneNumber]
146+
:return: The validated phone number
147+
:rtype: Optional[PhoneNumber]
148+
"""
149+
if phone_number is None:
150+
return None
151+
try:
152+
parsed_number = phonenumbers.parse(str(phone_number), None)
153+
except phonenumbers.phonenumberutil.NumberParseException as exc:
154+
raise ValueError from exc
155+
if not phonenumbers.is_valid_number(parsed_number):
156+
raise ValueError("Invalid phone number")
157+
return phone_number

app/crud/user.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ def __init__(
3434
):
3535
self.session: Session = session
3636

37-
def read_by_id(self, _id: PositiveInt) -> User | None:
37+
def read_by_id(self, _id: PositiveInt) -> User:
3838
"""
3939
Retrieve a user from the database by its id
4040
:param _id: The id of the user
4141
:type _id: IdSpecification
4242
:return: The user with the specified id, or None if no such
4343
user exists
44-
:rtype: Optional[User]
44+
:rtype: User
4545
"""
4646
with self.session as session:
4747
stmt: Select[Any]
@@ -54,7 +54,7 @@ def read_by_id(self, _id: PositiveInt) -> User | None:
5454
logger.error(sa_exc)
5555
logger.info("Retrieving row with id: %s", _id)
5656
raise DatabaseException(str(sa_exc)) from sa_exc
57-
return User(db_obj)
57+
return db_obj
5858

5959
def read_users(
6060
self,

app/schemas/user.py

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,124 @@
22
A module for user in the app-schemas package.
33
"""
44

5-
from pydantic import BaseModel
5+
from datetime import date
66

7+
from phonenumbers import PhoneNumber
8+
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
9+
from pydantic.config import JsonDict
10+
from pydantic_extra_types.phone_numbers import (
11+
PhoneNumber as PydanticPhoneNumber,
12+
)
713

8-
class UserCreate(BaseModel):
9-
pass
14+
from app.config.init_settings import init_setting
15+
from app.config.utils import validate_password, validate_phone_number
1016

17+
user_example: JsonDict = {
18+
"example": {
19+
"username": "username",
20+
"email": "[email protected]",
21+
"birthdate": date(2004, 1, 1).strftime(init_setting.DATE_FORMAT),
22+
"phone_number": str(PydanticPhoneNumber("+593987654321")),
23+
"password": "Hk7pH9*35Fu&3U",
24+
}
25+
}
1126

12-
class UserUpdate(BaseModel):
13-
pass
27+
28+
class UserBase(BaseModel):
29+
model_config = ConfigDict(
30+
json_schema_extra=user_example,
31+
)
32+
33+
username: str | None = Field(
34+
default=None,
35+
title="Username",
36+
description="Username to identify the user",
37+
min_length=4,
38+
max_length=15,
39+
)
40+
email: EmailStr | None = Field(
41+
default=None,
42+
title="Email",
43+
description="Preferred e-mail address of " "the User",
44+
)
45+
password: str | None = Field(
46+
default=None,
47+
title="Password",
48+
description="Password of the User",
49+
min_length=8,
50+
max_length=14,
51+
)
52+
birthdate: date | None = Field(
53+
default=None, title="Birthdate", description="Birthday of the User"
54+
)
55+
phone_number: PhoneNumber | None = Field(
56+
default=None,
57+
title="Phone number",
58+
description="Preferred telephone number of the User",
59+
)
60+
61+
@field_validator("phone_number", mode="before")
62+
def validate_phone_number(
63+
cls, v: PydanticPhoneNumber | None
64+
) -> PydanticPhoneNumber | None:
65+
"""
66+
Validates the phone number attribute
67+
:param v: The phone number value to validate
68+
:type v: Optional[PhoneNumber]
69+
:return: The validated phone number
70+
:rtype: Optional[PhoneNumber]
71+
"""
72+
return validate_phone_number(v)
73+
74+
@field_validator("password", mode="before")
75+
def validate_password(cls, v: str | None) -> str:
76+
"""
77+
Validates the password attribute
78+
:param v: The password to be validated
79+
:type v: Optional[str]
80+
:return: The validated password
81+
:rtype: str
82+
"""
83+
return validate_password(v)
84+
85+
86+
class UserCreate(UserBase):
87+
model_config = ConfigDict(
88+
from_attributes=True,
89+
)
90+
91+
username: str
92+
email: EmailStr
93+
password: str
94+
95+
96+
class UserUpdate(UserBase):
97+
"""
98+
Schema for updating a User record.
99+
"""
100+
101+
model_config = ConfigDict(
102+
from_attributes=True,
103+
)
104+
105+
106+
class User(UserBase):
107+
model_config = ConfigDict(
108+
from_attributes=True,
109+
)
110+
111+
password: str = Field(
112+
...,
113+
title="Hashed Password",
114+
description="Hashed Password of the User",
115+
min_length=60,
116+
max_length=60,
117+
)
118+
119+
120+
class UsersResponse(BaseModel):
121+
"""
122+
Class representation for a list of users response
123+
"""
124+
125+
users: list[User]

0 commit comments

Comments
 (0)