Data Transfer Objects (DTOs)#
Starlite includes a DTOFactory
class that allows you to create DTOs from pydantic models,
dataclasses, typing.TypedDict
, and any other class supported via plugins.
An instance of the factory must first be created, optionally passing plugins to it as a kwarg. It can then be used to
create a DTO
by calling the instance like a function. Additionally, it can exclude (drop)
attributes, remap field names and field types, and add new fields.
The created DTO
can be used for data parsing, validation and OpenAPI schema generation like a
regularly declared pydantic model.
Attention
Although the value generated is a pydantic factory, because it is being generated programmatically, it’s currently impossible to extend editor auto-complete for the DTO properties - it will be typed as DTO[T], with T being a generic argument representing the original model used to create the DTO.
Note
MyPy doesn’t support using types defined using Type[] as a type, and MyPy will regard these as invalid types. There is currently no way to circumvent this (not even with a plugin) except using a # type: ignore comment.
The DTOFactory
class supports plugins, for example, this
is how it could be used with an SQLAlchemy declarative class using the
SQLAlchemyPlugin:
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base
from starlite import DTOFactory
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin
dto_factory = DTOFactory(plugins=[SQLAlchemyPlugin()])
Base = declarative_base()
class Company(Base): # pyright: ignore
__tablename__ = "company"
id: Mapped[int] = Column(Integer, primary_key=True) # pyright: ignore
name: Mapped[str] = Column(String) # pyright: ignore
worth: Mapped[float] = Column(Float) # pyright: ignore
CompanyDTO = dto_factory("CompanyDTO", Company)
The created CompanyDTO
is equal to this pydantic model declaration:
from pydantic import BaseModel
class CompanyDTO(BaseModel):
id: int
name: str
worth: float
You can now use it in route handler functions as you would any other pydantic model. The one caveat though is lack of editor completion and mypy support - this requires the implementation of a mypy plugin, which is planned for the future.
Excluding Fields#
You can exclude any field in the original model class from the DTO
:
from pydantic import BaseModel
from starlite import DTOFactory
class MyClass(BaseModel):
first: int
second: int
dto_factory = DTOFactory()
MyClassDTO = dto_factory("MyClassDTO", MyClass, exclude=["first"])
The generated MyClassDTO
is equal to this model declaration:
from pydantic import BaseModel
class MyClassDTO(BaseModel):
second: int
Remapping Fields#
You can remap fields in two ways:
you can switch change their keys:
from pydantic import BaseModel
from starlite import DTOFactory
class MyClass(BaseModel):
first: int
second: int
dto_factory = DTOFactory()
MyClassDTO = dto_factory("MyClassDTO", MyClass, field_mapping={"first": "third"})
The generated MyClassDTO
is equal to this model declaration:
from pydantic import BaseModel
class MyClassDTO(BaseModel):
second: int
third: int
You can remap name and type. To do this use a tuple instead of a string for the object value:
from pydantic import BaseModel
from starlite import DTOFactory
class MyClass(BaseModel):
first: int
second: int
dto_factory = DTOFactory()
MyClassDTO = dto_factory("MyClassDTO", MyClass, field_mapping={"first": "third", "second": ("fourth", float)})
The generated MyClassDTO
is equal to this model declaration:
from pydantic import BaseModel
class MyClassDTO(BaseModel):
third: int
fourth: float
Add New Fields#
You add fields that do not exist in the original model by passing in a field_definitions
dictionary. This dictionary
should have field names as keys, and a tuple following the format supported by the
pydantic create_model helper:
For required fields use a tuple of type + ellipsis, for example
(str, ...)
.For optional fields use a tuple of type +
None
, for example(str, None)
To set a default value use a tuple of type + default value, for example
(str, "Hello World")
from pydantic import BaseModel
from starlite import DTOFactory
class MyClass(BaseModel):
first: int
second: int
dto_factory = DTOFactory()
MyClassDTO = dto_factory("MyClassDTO", MyClass, field_definitions={"third": (str, ...)})
The generated MyClassDTO
is equal to this model declaration:
from pydantic import BaseModel
class MyClassDTO(BaseModel):
first: int
second: int
third: str
DTO Methods#
DTO.from_model_instance#
Once you create a DTO class you can use its class method from_model_instance
to create an instance from an existing instance of the model from which the DTO was generated:
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base
from starlite import DTOFactory
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin
dto_factory = DTOFactory(plugins=[SQLAlchemyPlugin()])
Base = declarative_base()
class Company(Base): # pyright: ignore
__tablename__ = "company"
id: Mapped[int] = Column(Integer, primary_key=True) # pyright: ignore
name: Mapped[str] = Column(String) # pyright: ignore
worth: Mapped[float] = Column(Float) # pyright: ignore
CompanyDTO = dto_factory("CompanyDTO", Company)
company_instance = Company(id=1, name="My Firm", worth=1000000.0)
dto_instance = CompanyDTO.from_model_instance(company_instance)
In the above, dto_instance
is a validated pydantic model instance.
DTO.to_model_instance#
When you have an instance of a DTO
model, you can convert it into a model instance using the
to_model_instance
method:
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base
from starlite import DTOFactory, post
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin
dto_factory = DTOFactory(plugins=[SQLAlchemyPlugin()])
Base = declarative_base()
class Company(Base): # pyright: ignore
__tablename__ = "company"
id: Mapped[int] = Column(Integer, primary_key=True) # pyright: ignore
name: Mapped[str] = Column(String) # pyright: ignore
worth: Mapped[float] = Column(Float) # pyright: ignore
CompanyDTO = dto_factory("CompanyDTO", Company)
@post()
def create_company(data: CompanyDTO) -> Company: # type: ignore
return data.to_model_instance() # type: ignore
In the above company_instance
is an instance of the SQLAlchemy declarative class Company
. It is correctly typed as
Company
because the DTO
class uses generic to store this data.
Attention
If you exclude keys or add additional fields, you should make sure this does not cause an error when trying to generate a model class from a dto instance. For example, if you exclude required fields from a pydantic model and try to create an instance from a dto that doesn’t have these, a validation error will be raised.
Automatic Conversion on Response#
When you use a DTO as a return type in a route handler, if the returned data is a model or a dict, it will be converted to the DTO automatically:
from typing import List
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base
from starlite import DTOFactory, HTTPException, Starlite, get
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin
from starlite.status_codes import HTTP_404_NOT_FOUND
sqlalchemy_plugin = SQLAlchemyPlugin()
dto_factory = DTOFactory(plugins=[sqlalchemy_plugin])
Base = declarative_base()
class Company(Base): # pyright: ignore
__tablename__ = "company"
id: Mapped[int] = Column(Integer, primary_key=True) # pyright: ignore
name: Mapped[str] = Column(String) # pyright: ignore
worth: Mapped[float] = Column(Float) # pyright: ignore
secret: Mapped[str] = Column(String) # pyright: ignore
ReadCompanyDTO = dto_factory("CompanyDTO", Company, exclude=["secret"])
companies: List[Company] = [
Company(id=1, name="My Firm", worth=1000000.0, secret="secret"),
Company(id=2, name="My New Firm", worth=1000.0, secret="abc123"),
]
@get("/{company_id: int}")
def get_company(company_id: int) -> ReadCompanyDTO: # type: ignore
try:
return companies[company_id - 1]
except IndexError:
raise HTTPException(
detail="Company not found",
status_code=HTTP_404_NOT_FOUND,
)
@get()
def get_companies() -> List[ReadCompanyDTO]: # type: ignore
return companies
app = Starlite(
route_handlers=[get_company, get_companies],
plugins=[sqlalchemy_plugin],
)
from sqlalchemy import Column, Float, Integer, String
from sqlalchemy.orm import Mapped, declarative_base
from starlite import DTOFactory, HTTPException, Starlite, get
from starlite.plugins.sql_alchemy import SQLAlchemyPlugin
from starlite.status_codes import HTTP_404_NOT_FOUND
sqlalchemy_plugin = SQLAlchemyPlugin()
dto_factory = DTOFactory(plugins=[sqlalchemy_plugin])
Base = declarative_base()
class Company(Base): # pyright: ignore
__tablename__ = "company"
id: Mapped[int] = Column(Integer, primary_key=True) # pyright: ignore
name: Mapped[str] = Column(String) # pyright: ignore
worth: Mapped[float] = Column(Float) # pyright: ignore
secret: Mapped[str] = Column(String) # pyright: ignore
ReadCompanyDTO = dto_factory("CompanyDTO", Company, exclude=["secret"])
companies: list[Company] = [
Company(id=1, name="My Firm", worth=1000000.0, secret="secret"),
Company(id=2, name="My New Firm", worth=1000.0, secret="abc123"),
]
@get("/{company_id: int}")
def get_company(company_id: int) -> ReadCompanyDTO: # type: ignore
try:
return companies[company_id - 1]
except IndexError:
raise HTTPException(
detail="Company not found",
status_code=HTTP_404_NOT_FOUND,
)
@get()
def get_companies() -> list[ReadCompanyDTO]: # type: ignore
return companies
app = Starlite(
route_handlers=[get_company, get_companies],
plugins=[sqlalchemy_plugin],
)
In the above, when requesting route of a company, the secret
attribute will not be included in the response. And it
also works when returning a list of companies.
Creating partial DTOs#
Sometimes you may only need to partially modify a resource. In these cases, DTOs can be wrapped with
Partial
.
from pydantic import BaseModel
from starlite.types.partial import Partial
class CompanyDTO(BaseModel):
id: int
name: str
worth: float
PartialCompanyDTO = Partial[CompanyDTO]
The created PartialCompanyDTO
is equivalent to the following declaration:
from typing import Optional
from pydantic import BaseModel
class PartialCompanyDTO(BaseModel):
id: Optional[int]
name: Optional[str]
worth: Optional[float]
from pydantic import BaseModel
class PartialCompanyDTO(BaseModel):
id: int | None
name: str | None
worth: float | None
Partial
can also be used inline when creating routes.
from pydantic import UUID4, BaseModel
from starlite.controller import Controller
from starlite.handlers import patch
from starlite.types.partial import Partial
class UserOrder(BaseModel):
order_id: UUID4
order_item_id: UUID4
notes: str
class UserOrderController(Controller):
path = "/user"
@patch(path="/{order_id:uuid}")
async def update_user_order(
self, order_id: UUID4, data: Partial[UserOrder]
) -> UserOrder:
...