nerzh/swift-net-layer
SwiftNetLayer is a lightweight networking layer for describing HTTP APIs in Swift. It helps keep networking code split into predictable, readable files:
Installation
Add the package to Package.swift:
dependencies: [
.package(url: "https://github.com/nerzh/swift-net-layer.git", .upToNextMajor(from: "1.0.0"))
]Then add the product to your app target:
.target(
name: "App",
dependencies: [
.product(name: "SwiftNetLayer", package: "swift-net-layer")
]
)Import it where you describe your API:
import SwiftNetLayerBasic Structure
Usually one external API gets one folder:
Networking/
ExampleApi/
ExampleApi.swift
Targets/
UsersTarget.swift
BooksTarget.swift
ExampleModels.swiftExampleApi.swift is the resource. It knows the domain and default settings:
import Foundation
import SwiftNetLayer
final class ExampleApi: SNLResource {
static let shared = ExampleApi(token: "<token>")
init(token: String) {
super.init(
protocol: .https,
domain: "api.example.com",
version: "v1",
defaultHeaders: [
"Authorization": "Bearer \(token)",
"Accept": "application/json"
],
defaultParams: [
"locale": "en"
],
requestPerSecondOptions: .init(
.init(requestLimit: 5, timeRangeLimitSecond: 1)
)
)
}
func users() -> UsersTarget {
UsersTarget(resource: self, path: "/users")
}
func books() -> BooksTarget {
BooksTarget(resource: self, path: "/books")
}
}The base URL is:
https://api.example.com/v1And UsersTarget(resource: self, path: "/users") sends requests relative to:
https://api.example.com/v1/usersResource
SNLResource stores shared settings for the whole service:
super.init(
provider: SNLProvider(),
protocol: .https,
domain: "api.example.com",
version: "v1",
defaultHeaders: [
"Authorization": "Bearer \(token)"
],
defaultParams: [
"api_key": apiKey
],
requestPerSecondOptions: .init(
.init(requestLimit: 10, timeRangeLimitSecond: 1)
)
)Common resource-level values:
protocol-.httpor.https.domain- host without the protocol, for exampleapi.example.com. If your API uses part of the path as its base, values likemainnet.infura.io/v3are also valid.version- optional path component, for examplev1orapi/v2.defaultHeaders- auth token,Accept, or sharedContent-Type.defaultParams- params that should be included in every request, for exampleapi_key.requestPerSecondOptions- a simple limiter for APIs with request-per-second restrictions.
If you pass requestPerSecondOptions directly, you may need to import SwiftExtensionsPack, because the limiter is wrapped in SafeValue<RequestPerSecondOptions>.
Target
SNLTarget stores the path, headers, and params for a specific group of methods. For example, /users:
import Foundation
import SwiftNetLayer
final class UsersTarget: SNLTarget {
func list(limit: Int = 20) async throws -> [User] {
try await makeExecutor(
target: self,
resource: resource,
method: .get,
requestParams: [
"limit": limit
]
)
.execute(model: [User].self)
}
}Usage:
let users = try await ExampleApi.shared.users().list(limit: 50)If a target needs its own defaults, pass them when creating the target:
func users() -> UsersTarget {
UsersTarget(
resource: self,
path: "/users",
headers: [
"X-Feature": "new-users-api"
],
params: [
"include_deleted": false
]
)
}Headers and params are merged from three levels:
Resource defaults -> Target defaults -> Request valuesThat means a concrete target method can override values defined at the resource or target level.
If body == nil and requestHeaders does not contain Content-Type, the library adds Content-Type: application/x-www-form-urlencoded by default. For JSON requests, explicitly pass "Content-Type": "application/json" either on the resource or on the concrete request.
GET With Query Params
Targets/UsersTarget.swift:
import Foundation
import SwiftNetLayer
struct User: Decodable {
let id: Int
let name: String
}
final class UsersTarget: SNLTarget {
func list(page: Int, limit: Int) async throws -> [User] {
let params: SNLParams = [
"page": page,
"limit": limit
]
return try await makeExecutor(
target: self,
resource: resource,
method: .get,
requestParams: params
)
.execute(model: [User].self)
}
}If the resource has defaultParams: ["locale": "en"], the final request receives:
?locale=en&page=1&limit=20Dynamic Path Parts
If a path has a variable component, write it as :id, :method, or :chain, then pass replacements through dynamicPathsParts.
Resource:
final class ExampleApi: SNLResource {
func userDetails() -> UserDetailsTarget {
UserDetailsTarget(resource: self, path: "/users/:id")
}
}Target:
final class UserDetailsTarget: SNLTarget {
func get(id: Int) async throws -> User {
try await makeExecutor(
target: self,
resource: resource,
method: .get,
dynamicPathsParts: [
":id": "\(id)"
]
)
.execute(model: User.self)
}
}Usage:
let user = try await ExampleApi.shared.userDetails().get(id: 42)Final path:
/users/42The same pattern is useful for APIs shaped like /:chain/:method:
func stats() async throws -> Stats {
try await makeExecutor(
target: self,
resource: resource,
method: .get,
dynamicPathsParts: [
":chain": "bitcoin",
":method": "stats"
]
)
.execute(model: Stats.self)
}POST With JSON Body
For a JSON request, encode Data and pass it as body. You can define Content-Type at the resource level or on a concrete request.
struct CreateBookRequest: Encodable {
let title: String
let authorId: Int
}
struct Book: Decodable {
let id: Int
let title: String
}
final class BooksTarget: SNLTarget {
func create(title: String, authorId: Int) async throws -> Book {
let request = CreateBookRequest(title: title, authorId: authorId)
let body = try JSONEncoder().encode(request)
return try await makeExecutor(
target: self,
resource: resource,
method: .post,
requestHeaders: [
"Content-Type": "application/json"
],
body: body
)
.execute(model: Book.self)
}
}For JSON-RPC APIs, it is convenient to keep a helper on the resource:
struct JsonRpcRequest<Params: Encodable>: Encodable {
let jsonrpc = "2.0"
let id: Int
let method: String
let params: Params
}
extension ExampleApi {
static func jsonRpcBody<Params: Encodable>(
id: Int = 1,
method: String,
params: Params
) throws -> Data {
try JSONEncoder().encode(
JsonRpcRequest(id: id, method: method, params: params)
)
}
}Then use it in a target:
func balance(address: String) async throws -> BalanceResponse {
let body = try ExampleApi.jsonRpcBody(
method: "getBalance",
params: [address]
)
return try await makeExecutor(
target: self,
resource: resource,
method: .post,
requestHeaders: [
"Content-Type": "application/json"
],
body: body
)
.execute(model: BalanceResponse.self)
}Multipart And Files
For multipart requests, pass multipart: true and a files dictionary.
struct UploadResponse: Decodable {
let id: String
let url: String
}
final class FilesTarget: SNLTarget {
func uploadImage(data: Data) async throws -> UploadResponse {
let file = SNLFile(
data: data,
fileName: "image.png",
mimeType: SNLMIMEType.imagePNG.description
)
return try await makeExecutor(
target: self,
resource: resource,
method: .post,
multipart: true,
requestParams: [
"folder": "avatars"
],
files: [
"file": file
]
)
.execute(model: UploadResponse.self)
}
}requestParams become regular multipart fields, and files become file fields.
Responses
The most common option is decoding a Decodable model directly:
let user: User = try await ExampleApi.shared.userDetails().get(id: 1)If you also need URLResponse:
let output: (User, URLResponse) = try await makeExecutor(
target: self,
resource: resource,
method: .get
)
.execute(model: User.self)
let user = output.0
let response = output.1If you need raw data:
let data: Data = try await makeExecutor(
target: self,
resource: resource,
method: .get
)
.execute()Callback-style methods are also available:
try makeExecutor(
target: self,
resource: resource,
method: .get
)
.execute(model: User.self) { user, response, error in
// Handle callback result.
}Debug
To print URL, method, headers, params, body, and the multipart flag:
let user: User = try await makeExecutor(
target: self,
resource: resource,
method: .get,
dynamicPathsParts: [
":id": "42"
]
)
.execute(model: User.self, debug: true)The request information is printed to the console.
Timeouts
Timeouts can be configured per request:
let receipt = try await makeExecutor(
target: self,
resource: resource,
method: .post,
body: body,
timeoutIntervalForRequest: 30,
timeoutIntervalForResource: 60
)
.execute(model: Receipt.self)Rate Limit
If an API limits request frequency, define the limit on the resource:
final class BlockExplorerApi: SNLResource {
init(apiKey: String) {
super.init(
protocol: .https,
domain: "api.blockexplorer.example",
defaultParams: [
"key": apiKey
],
requestPerSecondOptions: .init(
.init(
requestLimit: 10,
timeRangeLimitSecond: 1,
retryDelaySecond: 100_000_000
)
)
)
}
}Before executing a request, SNLExecutor waits until a free request slot is available.
Complete Example: Users API
Networking/UsersApi/UsersApi.swift:
import Foundation
import SwiftNetLayer
final class UsersApi: SNLResource {
static let shared = UsersApi(token: "<token>")
init(token: String) {
super.init(
protocol: .https,
domain: "api.example.com",
version: "v1",
defaultHeaders: [
"Authorization": "Bearer \(token)",
"Accept": "application/json"
],
defaultParams: [
"client": "ios"
]
)
}
func users() -> UsersTarget {
UsersTarget(resource: self, path: "/users")
}
func user() -> UserTarget {
UserTarget(resource: self, path: "/users/:id")
}
}Networking/UsersApi/Targets/UserModels.swift:
import Foundation
struct User: Codable {
let id: Int
let name: String
let email: String
}
struct CreateUserRequest: Encodable {
let name: String
let email: String
}Networking/UsersApi/Targets/UsersTarget.swift:
import Foundation
import SwiftNetLayer
final class UsersTarget: SNLTarget {
func list(search: String? = nil, page: Int = 1) async throws -> [User] {
var params: SNLParams = [
"page": page
]
if let search {
params["search"] = search
}
return try await makeExecutor(
target: self,
resource: resource,
method: .get,
requestParams: params
)
.execute(model: [User].self)
}
func create(name: String, email: String) async throws -> User {
let body = try JSONEncoder().encode(
CreateUserRequest(name: name, email: email)
)
return try await makeExecutor(
target: self,
resource: resource,
method: .post,
requestHeaders: [
"Content-Type": "application/json"
],
body: body
)
.execute(model: User.self)
}
}Networking/UsersApi/Targets/UserTarget.swift:
import Foundation
import SwiftNetLayer
final class UserTarget: SNLTarget {
func get(id: Int) async throws -> User {
try await makeExecutor(
target: self,
resource: resource,
method: .get,
dynamicPathsParts: [
":id": "\(id)"
]
)
.execute(model: User.self)
}
func delete(id: Int) async throws -> Data {
try await makeExecutor(
target: self,
resource: resource,
method: .delete,
dynamicPathsParts: [
":id": "\(id)"
]
)
.execute()
}
}App usage:
let users = try await UsersApi.shared.users().list(search: "oleh")
let user = try await UsersApi.shared.user().get(id: users[0].id)
let created = try await UsersApi.shared.users().create(
name: "Ada",
email: "ada@example.com"
)Practical Tips
- One external service should usually have one
SNLResource. - One logical API section should usually have one
SNLTarget:UsersTarget,BooksTarget,WalletsTarget,RpcTarget. - Keep methods that return ready-to-use app models inside target files.
- Put shared headers and params on the resource.
- Put section-specific headers and params on the target.
- Put method-specific values in
requestParams,requestHeaders,body,files, ordynamicPathsParts. - Do not keep real API keys in README files or repositories. Pass them through configuration, environment, Keychain, or dependency injection.
Quick Reference
makeExecutor(
target: self,
resource: resource,
method: .get,
multipart: false,
dynamicPathsParts: [":id": "42"],
requestHeaders: ["Content-Type": "application/json"],
requestParams: ["limit": 20],
body: body,
files: ["file": file],
timeoutIntervalForRequest: 30,
timeoutIntervalForResource: 60
)Execution methods:
try await executor.execute()
try await executor.execute(debug: true)
try await executor.execute(model: User.self)
try await executor.execute(model: User.self, debug: true)
try executor.execute(model: User.self) { user, response, error in }
try executor.waitExecute(model: User.self) { user, response, error in }Package Metadata
Repository: nerzh/swift-net-layer
Default branch: master
README: README.md