A Swift Macro that generate DTOs.
- provides protocol
DecodableFromDTOProtocol
for DTO to easily encapsulate data - Provides an extension to decode object from DTO directly with JSONDecoder
- Generate boilerplate code for type conversion through DTOs
- Provides
@DecodableFromDTO
that does the code generation - provides
@ConvertDTOType(from: SourceType, to: BusinessDataType, convert: ConversionClosure)
that give information on how to convert data from one type to another - Provides
@ConvertFromDTO
a shorten version of the previous one for properties that are generated from DTOs - provides
@DTOProperty(name: "a_name")
to change the name of the DTO property to feat the received data
- Provides
- Swift 5.9+
- Xcode 15.0+
Add the following to your Package.swift
file:
dependencies: [
.package(url: "https://github.com/OctoPoulpeStudio/DTOMacro.git", from: "1.0.0")
]
- Import DTOMacro:
import DTOMacro
- Add the attribute
@DecodableFromDTO
to the struct name of your Business data
@DecodableFromDTO
struct MyData {
let name: String
let birthday:Date?
}
- Add one of the conversion attribute to the properties you want to convert.
3.1. @ConvertDTOType(from: SourceType, to: BusinessDataType, convert: ConversionClosure)
- SourceType : the type of the data you received
- BusinessType : the type of the property you want to convert
- ConversionClosure : An Explicite Closure to convert SourceType int BusinessType.
- > [!NOTE] : the body of the closure must be in the attribute since it is copied as a string by SwiftSyntaxt.
@DecodableFromDTO
struct MyData {
let name: String
@ConvertDTOType(from: String, to: Date, convert: {ISO8601DateFormatter().date(from:$0)})
let birthday:Date?
}
3.2. @ConvertFromDTO
to automatically convert an object that is created from DTO as well
@DecodableFromDTO
struct MyData {
let name: String
@ConvertFromDTO
let myProperty:ATypeUsingTheDecodableFromDTOProtocol
}
Example :
@DecodableFromDTO
struct MyData {
let name: String
@ConvertDTOType(from: String, to: Date, convert: {ISO8601DateFormatter().date(from:$0)})
let birthday:Date?
}
will generate :
extension MyData: DecodableFromDTOProtocol {
public struct DTO: Decodable {
public let name: String
public let birthdate: String
}
private struct DTOConversionProcessor {
fileprivate static var birthdate: (String) -> Date? = {
ISO8601DateFormatter().date(from: $0)
}
}
public init(from dto: DTO) {
self.name = dto.name
self.birthdate = DTOConversionProcessor.birthdate(dto.birthdate)
}
}
- You can also use the
@DTOProperty(name: "a_name")
attribute to change the name of a property in the DTO object to reflect the one you receive, similarly to usingCodingKeys
Like this :
@DecodableFromDTO
struct MyData {
@DTOProperty(name: "a_name")
let name: String
@DTOProperty(name: "date_of_birth")
@ConvertDTOType(from: String, to: Date?, convert: { ISO8601DateFormatter().date(from:$0)})
let birthdate: Date?
}
it will expand to :
extension MyData: DecodableFromDTOProtocol {
public struct DTO: Decodable {
public let a_name: String
public let date_of_birth: String
}
private struct DTOConversionProcessor {
fileprivate static var date_of_birth: (String) -> Date? = {
ISO8601DateFormatter().date(from: $0)
}
}
public init(from dto: DTO) {
self.name = dto.a_name
self.birthdate = DTOConversionProcessor.date_of_birth(dto.date_of_birth)
}
}
You can pass a access
parameter to the @DecodableFromDTO
attribute to define the accessibility of the DTO properties like follow :
@DecodableFromDTO(access: .internal)
struct MyData {...
Note
the private accessor is not available otherwise the data from the DTO to the main type can not be done.
By default the accessor is public
.
DTO stand for Data Transfer Object. It's a design pattern that allow the business data to be consistante with the need of the application.
By creating in intermediary data type in your language (Swift in this case) that represent the data structure as it is received by the app (usually from an API via Json)
For example you might receive data from the server named "birthdate" that represent a Date but is actually a String.
How do you do the conversion from string to a Date in a seamless action? You might use a special init(from:Decoder)
initialiser with specific code that you forgot how to write or you can use DTO to convert your data easily with this intermediary object.
This would look like this :
{
birthdate:"2023-07-07T17:06:40.0433333+02:00"
}
struct MyData {
let birthday:Date?
}
Here you cannot directly convert String
to Date?
.
So you can use the DTO design pattern
struct MyDataDTO {
let birthdate: String
}
And create a special init in your business data type :
struct MyData {
let birthday:Date?
init(from dto: MyDataDTO) {
self.birthdate = ISO8601DateFormatter().date(from:dto.birthday)
}
}
Now a better way to use DTO is to nest the DTO type into your business data, hence having a uniformed way of writing them that we can embed in a protocol DecodableFromDTOProtocol
. We can, at the same time, group them in an extension to keep our business data clean :
public protocol DecodableFromDTOProtocol {
associatedtype DTO: Decodable
init(from dto: DTO) throws
}
struct MyData {
let birthday:Date?
}
extension MyData: DecodableFromDTOProtocol {
public struct DTO {
let birthday: String
}
init(from dto: DTO) {
self.birthday = ISO8601DateFormatter().date(from:dto.birthday)
}
}
The code we have is easy to read but not really easy to write. As you can see it adds a lot of boilerplate code. We now have a nested struct name DTO that has the same properties as our main struct and we have a new initialiser that does the conversion.
Which mean that we have 3 places to take care of if any change occurs.
SwiftMacro is a feature of Swift 5.9 that allow us, developper, to create code that generates code at compile time.
So how to use SwiftMacro to rescue us?
The idea is to create an attached macro
that does all the work the DecodableFromDTO
. And use other attached macro that doesn't generate any code as a way to tag property by abusing the macro system to generate our own attribute.
Therefore the DecodableFromDTO
is mandatory to get any code generation.
and the other attributes @ConvertDTOType(from: APIType, to: BusinessType, convert: ConversionClosure)
and @ConvertFromDTO
just give context to DecodableFromDTO
but don't generate anything.
[!NOTE] :
- that by using the macro system we can pass parameters and type check them.
- these macros are also used to detect error in there usage.
The DecodableFromDTO
macro works as follow :
- Get all the properties of the type it's attached to.
- Check for any attribute on them.
- if any gather the sourceType, destinationType and conversion closure
- Create an extension to the type it's attached to.
- Add the conformance to DecodableFromDTOProtocol
- Add a new type nested into this extension named
DTO
- Fill
DTO
with all the properties of the main type.- by default type the properties with the type of the property from the type it's attached to.
- if any attribute have been found, type the DTO property with the sourceType
- Create a private enum to store all the conversion for each property with conversion attributes
- Fill it with the conversion closures
- Create an initialiser
init(from dto: DTO)
- Fill this initialiser with the main type property assignation from the properties in DTO.
- by default use directly the DTO property
- if any conversion attribute, assign the result from the conversion closure where the dto property has been passed as parameter
This package embed the DecodableFromDTOProtocol
protocol
It also embed an extension to JsonDecoder to directly and seamlessly get the business data from a JSON
extension JSONDecoder {
func decode<T>(_ type: T.Type, from data: Data) throws -> T where T: DecodableFromDTOProtocol {
try T(from: decode(T.DTO.self, from: data))
}
}
[!NOTE] : the protocol and the extension have been created by Luis Recuenco and were taken from his article in Better Programming