-
Notifications
You must be signed in to change notification settings - Fork 4
Declaring Web Services
You will most likely only have to declare one WebService
per project unless your project communicates with multiple services.
This is the entire WebService
protocol.
public protocol WebService {
associatedtype BasicResponse: Decodable
associatedtype ErrorResponse: AnyErrorResponse
// MARK: Core
static var shared: Self {get}
var baseURL: URL {get}
var sessionOverride: Session? {get} // Optional
// MARK: Authorization
var authorization: Authorization {get}
// MARK: Configuration
func configure<E: Endpoint>(_ request: inout URLRequest, for endpoint: E) throws // Optional
func configure<E: Endpoint>(_ encoder: inout JSONEncoder, for endpoint: E) throws // Optional
func configure<E: Endpoint>(_ decoder: inout JSONDecoder, for endpoint: E) throws // Optional
func configure<E: Endpoint>(_ encoder: inout XMLEncoder, for endpoint: E) throws // Optional
func configure<E: Endpoint>(_ decoder: inout XMLDecoder, for endpoint: E) throws // Optional
// MARK: Validation
func validate<E: Endpoint>(_ response: URLResponse, for endpoint: E) throws // Optional
func validate<E: Endpoint>(_ response: BasicResponse, for endpoint: E) throws // Optional
// MARK: Error Handling
func handle<E: Endpoint>(_ error: ErrorKind, response: URLResponse, from endpoint: E) -> ErrorHandling // Optional
}
Let's break down our options for implementing each piece.
This is perhaps the most complex part to customize. This is available if all successful responses from the service include the same field(s). For example, maybe every response includes a "status" property.
If this is not the case, you can simply set it to NoBasicResponse
:
struct MyService: WebService {
typealias BasicResponse = NoBasicResponse
// ...
}
If instead, there are universal properties, you can define a type for it:
struct MyService: WebService {
struct BasicResponse {
let status: String
}
// ...
}
Here, every response is required to have a "status" property to be considered successful.
Note: You can also perform extra validation on these basic responses
This allows you to define what error responses look like. If there is a parsing or validation error, the request will attempt to parse this type from the response to allow the service to provide a better error message.
If the service doesn't have a standard error format, you can just set it to NoErrorResponse
.
struct MyService: WebService {
typealias ErrorResponse = NoErrorResponse
// ...
}
However, if there is a standard error format, you can declare that type:
struct MyService: WebService {
struct ErrorResponse: AnyErrorResponse {
let message: String
}
// ...
}
The ErrorResponse must implement the AnyErrorResponse protocol which only requires a message
getter.
In the example above, it is expected that errors returned from the server include a message property. However, if they don't, the request will fallback to its default error.
This property simply defines an instance that should be used by default for all requests. Each request can specify a different instance, but this one will be used by default. Usually, this will just be a simple initialization of the type.
struct MyService: WebService {
let shared = MyService()
// ...
}
This is probably the most critical piece. The final URL for a request will be formed by adding that endpoint's path to this base URL.
struct MyService: WebService {
let baseURL = URL(string: "https://example.com/api/v1")!
// ...
}
Notice that the base URL can still include a base path.
This defines the authorization to include with each request based on the auth requirement of its endpoint
This is expected to change as the client is authenticated and de-authenticated.
You will probably want to declare it as a variable defaulting to none.
var authorization = Authorization.none
Then, once you have authenticated, you would set it.
MyService.shared.authorization = .basic(username: "user", password: "secret")
There are multiple types of authorization:
- Basic: Username and password
- Bearer: Base 64 token
- Custom: Custom header key and value
The session override lets you specify a URLSession to use. If you don't specify one or return nil, URLSession.shared will be used.
There are 3 optional ways you can configure a request as they are being created.
If want to modify a request before it goes, out you can implement this method.
struct MyService: WebService {
func configure<E: Endpoint>(_ request: inout URLRequest, for endpoint: E) throws {
request.addValue("SOME VALUE", forHTTPHeaderField: "KEY")
}
// ...
}
With these methods, you can configure the encoders and decoders for the input formats. Most notably, you might want to customize the date formats:
struct MyService: WebService {
func configure<E: Endpoint>(_ encoder: inout JSONEncoder, for endpoint: E) throws {
encoder.dateEncodingStrategy = .secondsSince1970
}
func configure<E: Endpoint>(_ decoder: inout JSONDecoder, for endpoint: E) throws {
decoder.dateDecodingStrategy = .secondsSince1970
}
func configure<E: Endpoint>(_ encoder: inout XMLEncoder, for endpoint: E) throws {
encoder.dateEncodingStrategy = .secondsSince1970
}
func configure<E: Endpoint>(_ decoder: inout XMLDecoder, for endpoint: E) throws {
decoder.dateDecodingStrategy = .secondsSince1970
}
// ...
}
However, just like with the URLRequest configuration, these methods are optional.
The final available customization is validation. You can validate two different parts of a response. Validation methods should throw an error if there is a problem, otherwise, they should simply return without incident.
This is your basic response from the underlying Foundation networking. For example, You may want to validate that the HTTPS response code is within the 200 range.
struct MyService: WebService {
func validate<E>(_ response: URLResponse, for endpoint: E) throws where E : Endpoint {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
guard statusCode != 201 else {
throw RequestError.custom("Shouldn't have created anything")
}
}
// ...
}
However, you are also free to not implement this.
This method is only called if you supply a custom basic response (as opposed to NoBasicResponse
).
It is called after successfully parsing the BasicResponse
from the response body. You can do whatever validation you want, for example, maybe you want to make sure the status is correct.
struct MyService: WebService {
func validate<E>(_ response: BasicResponse, for endpoint: E) throws where E : Endpoint {}
guard response.status == "Success" else {
throw RequestError.custom("Bad status: \(response.status)")
}
}
// ...
}
Again, this method is optional.
If an error occurs, you will have an opportunity to handle it automatically and transparently to the requester. To do that, you must implement the handle method.
func handle<E: Endpoint>(_ error: ErrorKind, response: URLResponse, from endpoint: E) -> ErrorHandling {
switch error {
case .plain:
return .redirect(to: URL(string: "https://example.com/redirected")!)
default:
return .none
}
}
The method gets passed a few things to help you determine if/how to handle the error. If you don't want to handle the error, simply return .none
.
Your only other option currently is to return a redirect command with a custom URL. If you return this, the same exact request will be repeated to the new URL and the result will go through the entire pipeline again. All of this will be entirely transparent to the requesting code so that it appears as if only one request was made.