Sharing Swift code between a backend and client app is one of the benefits you’ll get when working with Swift on a Server. You can create dedicated Swift Packages containing sharable logic for both client and backend.
While many developers mention sharing Swift code as one of the benefits of Swift on the server, it’s only sometimes immediately clear how to best do this. Sharing models is pretty straightforward, but how about sharing endpoints? While I started writing a backend for RocketSim in Swift, I decided to dive into a possible generic solution that allows you to reuse both models and endpoints with Vapor.
The importance of sharing code
If you can share code between your client and the backend, you’ll be able to set up constraints that ensure both sides work correctly together. Once you change a model structure in your backend, your client will automatically have to adapt to the new changes. You might not notice and run into unexpected decoding errors without reusing the same model layer.
Defining the project structure for sharing Swift code
Before diving into code examples, it’s essential to look at the package structure for sharing Swift code. We will be using Swift Package Manager to define our packages. If you’re new to Swift packages, read my article Swift Package Manager framework creation in Xcode.
When defining your package structure, you want to keep the number of unused dependencies as low as possible. For example, the package used by both client and backend should not fetch all Vapor-related dependencies since you won’t use them in your client app. In my case, I decided to go for the following structure:
- Client App
- Models & Endpoint definitions Package
- Backend Package
In other words, both the client and backend depend on a shared package containing all models and endpoint definitions.
Sharing code for models
It’s useful for you to share models like request responses to set up contracts for expected results between the client and the backend. Since we’re working with Swift packages, we have to take care of exposing essential parts using the public
keyword. Similar to fileprivate and private, the public keyword is part of the access control modifiers in Swift.
As an example, you might have a backend to return articles. The article struct could look as follows:
struct Article: Codable {
let author: String
let title: String
}
While this code works fine within the package, it won’t be usable for anyone depending on your shared layer. Therefore, we need to expose the type, parameters, and initializer:
public struct Article: Codable {
public let author: String
public let title: String
public init(author: String, title: String) {
self.author = author
self.title = title
}
}
We can now create new Article
instances from within the backend package to return as a request-response, but we can also use the Article
for decoding response data in our client layer.
Sharing Endpoint definitions
Sharing models Swift code is relatively easy compared to sharing endpoint definitions. The latter includes more factors like the HTTP method, path definition, request parameters, and response model type. All these have to be defined in a central place so that:
- The client can use the endpoint definition to perform requests with the backend
- The backend can use the endpoint definition to correctly configure endpoints
Like our models, we ensure client and backend expect similar endpoint behavior. If the backend changes anything in one of the endpoints, the client will have to adapt automatically.
We start by defining an APIEndpoint
protocol:
public enum HTTPMethod: String {
case GET, POST
}
public protocol APIEndpoint {
associatedtype BodyParameters: Codable
associatedtype Response: Codable
static var path: String { get }
static var httpMethod: HTTPMethod { get }
static var response: Response.Type { get }
var parameters: BodyParameters { get }
}
For now, it’s only supporting GET
and POST
, but you can easily add more cases if needed. The protocol defines two associated types:
BodyParameters
: a codable instance that you can use for request definition and response decodingResponse
: a codable instance to use on the backend for returning the correct response body, as well as to decoding response data inside the client
By using this protocol we can define an endpoint to get the article for a specific identifier:
public struct ArticleEndpoint: APIEndpoint {
public static let path: String = "remove"
public static let httpMethod: HTTPMethod = .POST
public static let response: Article.Type = Article.self
public let parameters: ArticleBody
}
public struct ArticleBody: Codable {
public let id: String
public init(id: String) {
self.id = id
}
}
Configuring endpoints inside your backend package
We now have to ensure our backend correctly configures all shared endpoints. To simplify this process, we will use a generic method as an extension of Vapor’s RoutesBuilder
:
extension SharedArticlesLogicPackage.HTTPMethod {
var nioHTTPMethod: NIOHTTP1.HTTPMethod {
switch self {
case .GET: return .GET
case .POST: return .POST
}
}
}
extension RoutesBuilder {
@discardableResult
func endpoint<T: APIEndpoint>(
_ endpoint: T.Type,
use closure: @escaping (Request, T.BodyParameters) async throws -> T.Response
) -> Route where T.Response: AsyncResponseEncodable
{
return self.on(endpoint.httpMethod.nioHTTPMethod, PathComponent(stringLiteral: endpoint.path)) { request in
let content = try request.content.decode(endpoint.BodyParameters)
return try await closure(request, content)
}
}
}
The code might look intimidating initially, but it’s building upon the same structure as Vapor’s endpoint definition methods. We have to extend our shared-package-defined HTTPMethod
to allow converting to a NIOHTTP1.HTTPMethod
instance. You can replace SharedArticlesLogicPackage
with the name of your shared package.
Now that we have this generic logic in place, we can start defining our API endpoint:
extension Article: Content { }
extension ArticleBody: Content { }
extension ArticleEndpoint {
static func register(with routes: RoutesBuilder) {
routes.endpoint(self) { request, requestBody in
let articleIdentifier = requestBody.id
/// Fetch the article for the given ID...
return Article(
author: "Antoine van der Lee",
title: "Share Swift Code between Swift On Server Vapor and Client App"
)
}
}
}
First, we must ensure Article
and ArticleBody
conform to Vapor’s Content
protocol. Secondly, we define a new static register method containing all logic required to make the endpoint work. Finally, we can register the endpoint with our routes builder:
func routes(_ app: Application) throws {
ArticleEndpoint.register(with: app)
}
Requesting endpoints inside your client
Our final step is to ensure we can call any defined endpoint from within our client. In this case, I’m sharing generic logic to perform POST requests:
@discardableResult
private func request<T: APIEndpoint>(_ endpoint: T) async throws -> T.Response {
let url = apiHost.baseURL
.appendingPathComponent(T.path)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = try encoder.encode(endpoint.parameters)
var headers = urlRequest.allHTTPHeaderFields ?? [:]
headers["Content-Type"] = "application/json"
urlRequest.allHTTPHeaderFields = headers
let (data, _) = try await URLSession.shared.data(for: urlRequest)
return try decoder.decode(T.response, from: data)
}
You’d like to optimize this code for all supported HTTP methods, but I’ll leave that up as an exercise for the reader. After defining the generic request method, we can start using it for our article endpoint:
func fetchArticle(identifier: String) async throws -> Article {
let requestBody = ArticleBody(id: identifier)
let requestEndpoint = ArticleEndpoint(parameters: requestBody)
let article = try await request(requestEndpoint)
return article
}
We’ve ensured the backend and client use the same endpoint and model definitions. Due to this contract, our client code will notify us of any changes inside the endpoint definition. For example, if we decide to change the id
parameter to identifier
:
Conclusion
Sharing Swift code between your client and backend allows you to ensure both sides are working with the same layer. You prevent yourself from running into unexpected request failures due to a mismatch in an endpoint or model definition. Using a few generic Vapor extensions, we can simplify endpoint definitions.
If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!