Meet Snowdrop - type-safe, easy to use framework powered by Swift Macros created to let you build and maintain complex network requests with ease.
Snowdrop is available via SPM. It works with iOS Deployment Target 14.0 or later. If you code for macOS, your Deployment Target has to be 11 or newer.
- Type-safe service creation with
@Service
macro - Support for various request method types such as
@GET
@POST
@PUT
@DELETE
@PATCH
@CONNECT
@HEAD
@OPTIONS
@QUERY
@TRACE
- SSL/Certificate pinning
- Interceptors
- Mockable
Creating network services with Snowdrop is really easy. Just declare a protocol along with its functions.
@Service
protocol MyEndpoint {
@GET(url: "/posts")
@Headers(["X-DeviceID": "testSim001"])
func getAllPosts() async throws -> [Post]
}
If your request includes some dynamic values, such as id
, you can add it to your path wrapping it with {}
. Snowdrop will automatically bind your function declaration's arguments with those you include in request's path.
@GET(url: "/posts/{id}")
func getPost(id: Int) async throws -> Post
Upon expanding macros, Snowdrop creates a class MyEndpointService
which implements MyEndpoint
protocol and generates all the functions you declared.
class MyEndpointService: MyEndpoint {
func getAllPosts() async throws -> [Post] {
// auto-generated body
}
func getPost(id: Int) async throws -> Post {
// auto-generated body
}
}
Please note that if your service protocol already have "Service" keyword like MyEndpointService
, macro will then generate the class named MyEndpointServiceImpl
instead.
To send requests, just initialize MyEndpointService
instance and call function corresponding to the request you want to execute.
let service = MyEndpointService(baseUrl: URL(string: "https://my-endpoint.com")!)
let post = try await service.getPost(id: 7)
If you need to change default json decoder, you can set your own decoder when creating an instance of your service.
let decoder = CustomJSONDecoder()
let service = MyEndpointService(baseUrl: URL(string: "https://my-endpoint.com")!, decoder: decoder)
Snowdrop offers SSL/Certificate pinning functionality when executing network requests. You can turn it on/off when creating an instance of your service. You can also determine urls that should be excluded from pinning.
let service = MyEndpointService(baseUrl: URL(string: "https://my-endpoint.com")!, pinningMode: .ssl, urlsExcludedFromPinning: ["https://my-endpoint.com/about"])
If you want to put some encodable object as a body of your request, you can either put it in your declaration as "body" argument or - if you want to use another name - use @Body
macro like:
@POST(url: "/posts")
@Body("model")
func addPost(model: Post) async throws -> Data
If you want to declare service's function that sends some file to the server as multipart/form-data
, use @FileUpload
macro. It'll automatically add Content-Type: multipart/form-data
to the request's headers and extend the list of your function's arguments with _payloadDescription: PayloadDescription
which you should then use to provide information such as name
, fileName
and mimeType
.
For mime types such as jpeg, png, gif, tiff, pdf, vnd, plain, octetStream, you don't have to provide PayloadDescription
. Snowdrop can automatically recognize them and create PayloadDescription
for you.
@Service
protocol MyEndpoint {
@FileUpload
@Body("image")
@POST(url: "/uploadAvatar/")
func uploadImage(_ image: UIImage) async throws -> Data
}
let payload = PayloadDescription(name: "avatar", fileName: "filename.jpeg", mimeType: "image/jpeg")
let service = MyEndpointService(baseUrl: URL(string: "https://my-endpoint.com")!)
_ = try await service.uploadImage(someImage, _payloadDescription: payload)
Upon expanding macros, Snowdrop adds argument _queryItems: [QueryItem]
to every service's function. For dynamic query parameters it's recommended to pass them using this argument like:
@Service
protocol MyEndpoint {
@GET(url: "/posts/{id}")
func getPost(id: Int) async throws -> Post
}
let authorName = "John Smith"
let service = MyEndpointService(baseUrl: URL(string: "https://my-endpoint.com")!)
let post = try await service.getPost(id: 7, _queryItems: [.init(key: "author", value: authorName)])
Snowdrop allows you to define custom values for your arguments. Let's say your path includes {id}
argument. As you already know by now, Snowdrop automatically associates it with id
argument of your func
declaration. If you want it to have default value equal "3", do it like: {id=3}
. Be careful though as Snowdrop won't check if your default value's type conforms to the declaration.
When inserting String
default values such as {name="Some name"}, it is strongly recommended to use Raw String
like @GET(url: #"/authors/{name="John Smith"}"#)
.
Each service provides two methods to add interception blocks - addBeforeSendingBlock
and addOnResponseBlock
. Both accept arguments such as path
of type String
and block
which is closure.
To add addBeforeSendingBlock
or addOnResponseBlock
for a requests matching certain path, use regular expressions like:
service.addBeforeSendingBlock(for: "my/path/[0-9]{1,}/content") { urlRequest in
// some operations
return urlRequest
}
To add addBeforeSendingBlock
or addOnResponseBlock
for ALL requests, do it like:
service.addOnResponseBlock { data, httpUrlResponse in
// some operations
return data
}
Note that if you add interception block for a certain request path, general interceptors will be ignored.
If you'd like to create mockable version of your service, Snowdrop got you covered. Just add @Mockable
macro to your service declaration like
@Service
@Mockable
protocol Endpoint {
@Get("/path")
func getPosts() async throws -> [Posts]
}
Snowdrop will automatically create a EndpointServiceMock
class with all the properties Service
should have and additional properties such as getPostsResult
to which you can assign value that should be returned.
func testEmptyArrayResult() async throws {
let mock = EndpointServiceMock(baseUrl: URL(string: "https://some.url")!
mock.getPostsResult = .success([])
let result = try await mock.getPosts()
XCTAssertTrue(result.isEmpty)
}
Note that mocked methods will directly return stubbed result without accessing Snowdrop.Core so your beforeSend and onResponse blocks won't be called.
If you'd like to test your service against mocked JSONs, you can easily do it. Just make sure you got your JSON mock somewhere in your project files, then instantiate your service and determine for which request your mock should be injected like in the example below.
func testJSONMockInjectsion() async throws {
let service = MyEndpointService(baseUrl: someBaseURL)
service.testJSONDictionary = ["users/123/info": "MyJSONMock"]
let result = try await service.getUserInfo(id: 123)
XCTAssertTrue(result.firstName, "JSON")
XCTAssertTrue(result.lastName, "Bourne")
}
If you'd like to get see logs from Snowdrop, use verbose
flag when creating new instance of your service.
let service = MyEndpointService(baseUrl: URL(string: "https://my-endpoint.com")!, verbose: true)
Retrofit was an inspiration for Snowdrop.