ALTENNetwork
es una librería creada con el fin de facilitar la creación y la llamada de peticiones con URLSession
. Añade la capacidad de usar Async/Await
a URLSession
desde iOS/tvOS 13
y proporciona un el objeto NetworkRequest
que facilita la creación de un URLRequest
con los parámetros más comunes.
Abrir Xcode y e ir al apartado File > Add Packages...
. En el cuadro de búsqueda introducir la url del respositorio y seleccionar la versión:
https://github.com/SDOSLabs/ALTENNetwork.git
dependencies: [
.package(url: "https://github.com/SDOSLabs/ALTENNetwork.git", .upToNextMajor(from: "4.0.0"))
]
Se debe añadir al target de la aplicación en la que queremos que esté disponible
.target(
name: "MyTarget",
dependencies: [
.product(name: "ALTENNetwork", package: "ALTENNetwork")
]),
La librería usa el protocolo NetworkSession
como core de su funcionalidad, que proporciona soporte para realizar llamadas a internet con Async/Await
desde iOS/tvOS 13
en adelante.
Para usar la librería hay que crear una clase que implemente el protocolo NetworkSession
. Por defecto, sólo es necesario pasarle la URLSession
que se usará para hacer las llamadas a servicios.
final actor AppURLSession: NetworkSession {
var session: URLSession
init(session: URLSession) {
self.session = session
}
}
Todos los demás métodos del protocolo están implementados y no es necesario hacer nada más para usarla, pero se pueden sobrescribir si fuera necesario.
A partir de aquí, sólo será necesario crear un objeto del tipo AppURLSession
para hacer las llamadas a servicios.
let networkSession: NetworkSession = AppURLSession(session: URLSession(configuration: configuration, delegate: nil, delegateQueue: nil))
func doRequest() async throws -> Data {
let url = "https://alten.es"
let result = try await networkSession.requestData(for: url)
return result.data
}
La definición del protocolo NetworkSession
es la siguiente:
/// Contiene todos los métodos disponibles para realizar llamadas con async/await
public protocol NetworkSession {
/// Inicializador de la clase
/// - Parameter session: `URLSession` que se encargará de realizar
init(session: URLSession)
/// `URLSession` que se encarga de realizar las peticiones
var session: URLSession { get set }
/// Este método se llamará cuando se finalice cualquier request. Tiene como parámetro de entrada el `NetworkSession` que realiza la petición y la `URLRequest` original a la que se está llamando.
/// Este método permite interceptar la respuesta antes de continuar con el flujo, pudiendo forzar el reintento de la petición con una nueva `URLRequest`
/// Por ejemplo, se puede usar para controlar los códigos de error 401, permitiendo realizar el refresco de un token y reintentar la petición
/// - Parameters:
/// - result: Resultado de la petición. En caso de que la petición haya tenido respuesta del servidor contendrá un `NetworkSessionInterception`. En cualquier otro caso contendrá un `Error`
/// - networkSession: `NetworkSession` que realiza la petición
/// - originalRequest: `URLRequest` original a la que se está llamando
/// - retryNumber: Número de intentos que se han realizado para la petición. El primer intento es 0.
/// - Returns: `NetworkSessionInterceptionResult` que indica si se debe continuar con el flujo o si se debe reintentar la petición con una nueva `URLRequest`
func interceptResponse(networkSession: NetworkSession, originalRequest: URLRequest, retryNumber: Int, result: Result<NetworkSessionInterception, Error>) async throws -> NetworkSessionInterceptionResult
/// Este método se llamará antes de que comience cualquier request. Tiene como parámetro de entrada la URLRequest a la que se está llamando. Este método tiene como objetivo modificar la Request antes de que se realice la petición.
/// La implementación por defecto imprime el curl de la request sólo en entornos de debug a través de la condificón `#if DEBUG`
/// - Parameters:
/// - networkSession: `NetworkSession` que realiza la petición
/// - originalRequest: `URLRequest` original a la que se está llamando
/// - Returns: `URLRequest` que se debe realizar
func interceptRequest(networkSession: NetworkSession, originalRequest: URLRequest) async -> URLRequestConvertible
// Descarga el contenido de un `URLRequestConvertible` y lo almacena en memoria. `URLRequestConvertible` es en esencia un `URLRequest`. De forma básica podemos usar un `URL` o un `URLRequest` para realizar la petición
/// - Parameters:
/// - request: `URLRequestConvertible` que se debe llamar para la descarga del contenido
/// - delegate: Delegado que recibe los eventos del ciclo de vida de la petición
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
@available(iOS 15, tvOS 15, *)
func requestData(for request: URLRequestConvertible, delegate: URLSessionTaskDelegate?) async throws -> NetworkDataResponse
/// Descarga el contenido de un `URLRequestConvertible` y lo almacena en memoria. `URLRequestConvertible` es en esencia un `URLRequest`. De forma básica podemos usar un `URL` o un `URLRequest` para realizar la petición
/// - Parameters:
/// - request: `URLRequestConvertible` que se debe llamar para la descarga del contenido
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
func requestData(for request: URLRequestConvertible) async throws -> NetworkDataResponse
/// Descarga el contenido de una `URL` dado en formato `String`
/// - Parameters:
/// - str: `String` que se debe llamar para la descarga del contenido
/// - delegate: Delegado que recibe los eventos del ciclo de vida de la petición
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
@available(iOS 15, tvOS 15, *)
func requestData(for str: String, delegate: URLSessionTaskDelegate?) async throws -> NetworkDataResponse
/// Descarga el contenido de una `URL` dado en formato `String`
/// - Parameters:
/// - str: `String` que se debe llamar para la descarga del contenido
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
func requestData(for str: String) async throws -> NetworkDataResponse
/// Descarga el contenido de un `URLRequestConvertible` y lo almacena en un fichero en disco. `URLRequestConvertible` es en esencia un `URLRequest`. De forma básica podemos usar un `URL` o un `URLRequest` para realizar la petición
/// - Parameters:
/// - request: `URLRequestConvertible` que se debe llamar para la descarga del contenido
/// - delegate: Delegado que recibe los eventos del ciclo de vida de la petición
/// - Returns: Respuesta del servidor que contiene `URL` con la ruta del contenido descargado y `URLResponse`
@available(iOS 15, tvOS 15, *)
func requestDownload(for request: URLRequestConvertible, delegate: URLSessionTaskDelegate?) async throws -> NetworkDownloadResponse
/// Descarga el contenido de un `URLRequestConvertible` y lo almacena en un fichero en disco. `URLRequestConvertible` es en esencia un `URLRequest`. De forma básica podemos usar un `URL` o un `URLRequest` para realizar la petición
/// - Parameters:
/// - request: `URLRequestConvertible` que se debe llamar para la descarga del contenido
/// - Returns: Respuesta del servidor que contiene `URL` con la ruta del contenido descargado y `URLResponse`
func requestDownload(for request: URLRequestConvertible) async throws -> NetworkDownloadResponse
/// Descarga el contenido de una `URL` dado en formato `String`
/// - Parameters:
/// - str: `String` que se debe llamar para la descarga del contenido
/// - delegate: Delegado que recibe los eventos del ciclo de vida de la petición
/// - Returns: Respuesta del servidor que contiene `URL` con la ruta del contenido descargado y `URLResponse`
@available(iOS 15, tvOS 15, *)
func requestDownload(for str: String, delegate: URLSessionTaskDelegate?) async throws -> NetworkDownloadResponse
/// Descarga el contenido de una `URL` dado en formato `String`
/// - Parameters:
/// - str: `String` que se debe llamar para la descarga del contenido
/// - Returns: Respuesta del servidor que contiene `URL` con la ruta del contenido descargado y `URLResponse`
func requestDownload(for str: String) async throws -> NetworkDownloadResponse
// Realiza la subida de contenido a un `URLRequestConvertible`. `URLRequestConvertible` es en esencia un `URLRequest`. De forma básica podemos usar un `URL` o un `URLRequest` para realizar la petición
/// - Parameters:
/// - request: `URLRequestConvertible` que se debe llamar para la subida del contenido
/// - bodyData: `Data` que debe enviar al servidor
/// - delegate: Delegado que recibe los eventos del ciclo de vida de la petición
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
@available(iOS 15, tvOS 15, *)
func requestUpload(for request: URLRequestConvertible, from bodyData: Data, delegate: URLSessionTaskDelegate?) async throws -> NetworkUploadResponse
/// Realiza la subida de contenido a un `URLRequestConvertible`. `URLRequestConvertible` es en esencia un `URLRequest`. De forma básica podemos usar un `URL` o un `URLRequest` para realizar la petición
/// - Parameters:
/// - request: `URLRequestConvertible` que se debe llamar para la subida del contenido
/// - bodyData: `Data` que debe enviar al servidor
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
func requestUpload(for request: URLRequestConvertible, from bodyData: Data) async throws -> NetworkUploadResponse
/// Realiza la subida de contenido a una `URL` dado en formato `String`
/// - Parameters:
/// - str: `String` que se debe llamar para la descarga del contenido
/// - bodyData: `Data` que debe enviar al servidor
/// - delegate: Delegado que recibe los eventos del ciclo de vida de la petición
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
@available(iOS 15, tvOS 15, *)
func requestUpload(for str: String, from bodyData: Data, delegate: URLSessionTaskDelegate?) async throws -> NetworkUploadResponse
/// Realiza la subida de contenido a una `URL` dado en formato `String`
/// - Parameters:
/// - str: `String` que se debe llamar para la descarga del contenido
/// - bodyData: `Data` que debe enviar al servidor
/// - delegate: Delegado que recibe los eventos del ciclo de vida de la petición
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
func requestUpload(for str: String, from bodyData: Data) async throws -> NetworkUploadResponse
/// Realiza la subida de contenido a un `URLRequestConvertible`. `URLRequestConvertible` es en esencia un `URLRequest`. De forma básica podemos usar un `URL` o un `URLRequest` para realizar la petición
/// - Parameters:
/// - request: `URLRequestConvertible` que se debe llamar para la subida del contenido
/// - fromFile: `URL` del fichero que se debe enviar al servidor
/// - delegate: Delegado que recibe los eventos del ciclo de vida de la petición
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
@available(iOS 15, tvOS 15, *)
func requestUpload(for request: URLRequestConvertible, fromFile fileURL: URL, delegate: URLSessionTaskDelegate?) async throws -> NetworkUploadResponse
/// Realiza la subida de contenido a un `URLRequestConvertible`. `URLRequestConvertible` es en esencia un `URLRequest`. De forma básica podemos usar un `URL` o un `URLRequest` para realizar la petición
/// - Parameters:
/// - request: `URLRequestConvertible` que se debe llamar para la subida del contenido
/// - fromFile: `URL` del fichero que se debe enviar al servidor
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
func requestUpload(for request: URLRequestConvertible, fromFile fileURL: URL) async throws -> NetworkUploadResponse
/// Realiza la subida de contenido a una `URL` dado en formato `String`
/// - Parameters:
/// - str: `String` que se debe llamar para la descarga del contenido
/// - fromFile: `URL` del fichero que se debe enviar al servidor
/// - delegate: Delegado que recibe los eventos del ciclo de vida de la petición
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
@available(iOS 15, tvOS 15, *)
func requestUpload(for str: String, fromFile fileURL: URL, delegate: URLSessionTaskDelegate?) async throws -> NetworkUploadResponse
/// Realiza la subida de contenido a una `URL` dado en formato `String`
/// - Parameters:
/// - str: `String` que se debe llamar para la descarga del contenido
/// - fromFile: `URL` del fichero que se debe enviar al servidor
/// - Returns: Respuesta del servidor que contiene `Data` y `URLResponse`
func requestUpload(for str: String, fromFile fileURL: URL) async throws -> NetworkUploadResponse
}
Para usar cualquiera de estas funciones hace falta invocarla desde un contexto asíncrono:
let networkSession: NetworkSession = AppURLSession(session: URLSession(configuration: configuration, delegate: nil, delegateQueue: nil))
func doRequest() async throws -> Data {
let url = "https://alten.es"
let result = try await networkSession.requestData(for: url)
return result.data
}
Podemos usar la función interceptRequest
para interceptar la petición antes de realizarla y modificarla. Por ejemplo, podemos usarlo para añadir cabeceras a la petición.
extension AppURLSession {
func interceptRequest(networkSession: NetworkSession, originalRequest: URLRequest) async -> URLRequestConvertible {
var request = originalRequest
request.addValue("Token", forHTTPHeaderField: "Authorization")
return request
}
}
También podemos usar esta función para imprimir por consola la respuesta de la petición.
extension AppURLSession {
func interceptRequest(networkSession: NetworkSession, originalRequest: URLRequest) async -> URLRequestConvertible {
#if DEBUG
print("[NetworkSession] - Start Request: \(originalRequest.curl)")
#endif
return originalRequest
}
}
Podemos usar la función interceptResponse
para interceptar la respuesta antes de continuar con el flujo y detectar posibles errores o respuestas que debemos manejar de forma genérica.
Por ejemplo, podemos usarlo para refrescar un token cuando la respuesta es un código de error 401.
extension AppURLSession {
func interceptResponse(networkSession: NetworkSession, originalRequest: URLRequest, retryNumber: Int, result: Result<NetworkSessionInterception, Error>) async throws -> NetworkSessionInterceptionResult {
guard retryNumber < 1 else { return .nothing }
var httpURLRespone: HTTPURLResponse? = nil
switch result {
case .success(.data(let dataResponse)):
if let _httpURLRespone = dataResponse.response as? HTTPURLResponse {
httpURLRespone = _httpURLRespone
}
case .success(.download(let dataResponse)):
if let _httpURLRespone = dataResponse.response as? HTTPURLResponse {
httpURLRespone = _httpURLRespone
}
case .success(.upload(let dataResponse)):
if let _httpURLRespone = dataResponse.response as? HTTPURLResponse {
httpURLRespone = _httpURLRespone
}
case .failure(let error):
throw error
}
if let httpURLRespone, httpURLRespone.statusCode == 401 {
let newRequest = try await refreshToken() // Implement refresh and return a new request with others authentication headers
return .retry(newRequest)
}
return .nothing
}
}
También podemos usar esta función para imprimir por consola la respuesta de la petición.
extension AppURLSession {
func interceptResponse(networkSession: NetworkSession, originalRequest: URLRequest, retryNumber: Int, result: Result<NetworkSessionInterception, Error>) async throws -> NetworkSessionInterceptionResult {
#if DEBUG
switch result {
case .success(.data(let response)):
print("[NetworkSession] - Intercept Response: \(response.data)")
case .success(.download(let response)):
print("[NetworkSession] - Intercept Response: File downloaded at path \(response.url)")
case .success(.upload(let response)):
print("[NetworkSession] - Intercept Response: \(response.data)")
case .failure(let error):
print("[NetworkSession] - Intercept Response: \(error)")
}
#endif
return .nothing
}
}
También proporciona la clase NetworkRequest
como forma sencilla de crear peticiones con los parámetros más comunes:
let networkSession: NetworkSession = AppURLSession(session: URLSession(configuration: configuration, delegate: nil, delegateQueue: nil))
func getFilms(searchText: String, page: Int) async throws -> Data {
let networkRequest = try NetworkRequest(url: "https://www.omdbapi.com/", query: [
.query(key: "apikey", value: "xxxxxxxx"),
.query(key: "page", value: "\(page)"),
.query(key: "s", value: searchText)
])
let result = try await networkSession.requestData(for: networkRequest)
return result.data
}
La clase NetworkRequest
tiene varios inicializadores y permiten las configuraciones más comunes que suelen tener un URLRequest
. Internamente la clase NetworkRequest
implementa el protocolo URLRequestConvertible
, que es el requisito para poder transformarlo en un URLRequest
.
Los parámetros de la query se pueden pasar como un tipo NetworkQuery
. El valor de estos parámetros no debe estar codificado para el envio en la url. La propia librería se encarga de codificarlo.
La clase NetworkRequest
es extensible y se puede heredar para complementarlo en base a las necesidades del proyecto.
También es posible crearse un componente totalmente personalizado que implemente el protocolo URLRequestConvertible
para poder usarlo en las peticiones.
La librería permite enviar parámetros en json fácilmente. Para ello debemos tener una estructura de datos que implemente el protocolo Encodable
y pasarselo en el parámetro jsonBody
. Esto hará que el body de la petición sea el json de dicha estructura.
let networkSession: NetworkSession = AppURLSession(session: URLSession(configuration: configuration, delegate: nil, delegateQueue: nil))
func getFilms(searchText: String, page: Int) async throws -> Data {
let networkRequest = try NetworkRequest(
url: "https://www.omdbapi.com/",
httpMethod: .post,
headers: nil,
query: nil,
jsonBody: FilmRequestDTO(apiKey: "xxxxxxxx", s: searchText, page: page),
encoder: JSONEncoder())
let result = try await networkSession.requestData(for: networkRequest)
return result.data
}
Al utilizar estas funciones se incluye automáticamente la cabecera Content-Type: application/json
a la request.
La clase NetworkRequest
puede crear request para soportar llamadas de tipo multipart/form-data
. Estas peticiones tienen una estructura del body específica y la librería permite crearla fácilmente usando el parámetro multipartForm
del inicializador.
let networkSession: NetworkSession = AppURLSession(session: URLSession(configuration: configuration, delegate: nil, delegateQueue: nil))
func uploadImage(image: Data) async throws -> Data {
let networkRequest = try NetworkRequest(
url: "https://endpoint.com/upload/image",
httpMethod: .post,
headers: nil,
query: nil,
multipartForm: NetworkMultipartFormFileRequest(name: "image", filename: "profile.png", value: image, contentType: "application/png"))
let result = try await networkSession.requestUpload(for: networkRequest)
return result.data
}
El parámetro multipartForm
recibe un array de NetworkMultipartFormDataConvertible
. La librería tiene 3 estructuras que implementan este protocolo:
NetworkMultipartFormJsonRequest
: Se usa cuando se quiera enviar un jsonNetworkMultipartFormFileRequest
: Se usa cuando se queira enviar un ficheroNetworkMultipartFormDataRequest
: Se usa cuando se quiera enviar un tipo de dato no contemplado en los otros casos
Esa implementación puede ser extendida creando nuestra propia implementación del protocolo NetworkMultipartFormDataConvertible
.
La librería también encapsula la respuesta de las peticiones en un objeto NetworkDataResponse
, NetworkDownloadResponse
o NetworkUploadResponse
(dependiendo del método de petición utilizado) para facilitar el tratamiento de los datos.
Sobre estos objetos existen algunas funciones útiles de validación de la petición y codificación de los datos desde json
que serán muy útiles para cualquier proyecto:
let networkSession: NetworkSession = AppURLSession(session: URLSession(configuration: configuration, delegate: nil, delegateQueue: nil))
func getFilms(searchText: String, page: Int) async throws -> FilmsSearchDTO<[FilmDTO]> {
let networkRequest = try NetworkRequest(url: "https://www.omdbapi.com/", query: [
.query(key: "apikey", value: "xxxxxxxx"),
.query(key: "page", value: "\(page)"),
.query(key: "s", value: searchText)
])
let result = try await networkSession.requestData(for: networkRequest).validate().jsonDecode(FilmsSearchDTO<[FilmDTO]>.self)
return result
}
También se pueden crear extensiones de NetworkDataResponse
, NetworkDownloadResponse
y NetworkUploadResponse
para crear funciones de utilidades como por ejemplo para mostrar por consola la respuesta recibida o validar la petición de forma diferente:
extension NetworkDataResponse {
public func logResponse() -> Self {
if let text = String(data: data, encoding: .utf8) {
print("[NetworkDataResponse] String: \(text)")
} else {
print("[NetworkDataResponse] Data recieved is not a String: \(data.count) bytes")
}
return self
}
}
extension NetworkDataResponse {
public func validateApi(correctRange range: HTTPCodes = .success) throws -> Self {
guard let response = response as? HTTPURLResponse else {
throw NetworkError.responseData(.invalidResponse(self))
}
let range: HTTPCodes = .success
guard range ~= response.statusCode else {
if 400..<500 ~= response.statusCode {
if response.statusCode == 401 {
throw ErrorApp.networkUnauthorized
} else {
if let object = try? JSONDecoder().decode(AppErrorDTO.self, from: data) {
throw <#T##Your Error#>
} else {
throw NetworkError.responseData(.invalidStatusCode(self, response.statusCode))
}
}
} else {
throw NetworkError.responseData(.invalidStatusCode(self, response.statusCode))
}
}
return try validate(correctRange: range)
}
}
La clase NetworkReachability
permite la suscripción a un AsyncThrowingStream<NetworkReachability, Error>
que notificará de los cambios de red que se produzcan en el dispositivo.
Ejemplo:
En un contexto no asíncrono
func checkConnection() {
if let reachability = try? NetworkReachability(), let notifier = try? reachability.startNotifier() {
self.reachability = reachability // retain `reachability`
Task {
for try await reachability in notifier {
print("Connection type: \(reachability.connection.rawValue)")
}
print("Finish Reachability")
}
}
}
En un contexto asíncrono
func checkConnection() async throws {
if let reachability = try? NetworkReachability(), let notifier = try? reachability.startNotifier() {
self.reachability = reachability // retain `reachability`
Task {
for try await reachability in notifier {
print("Connection type: \(reachability.connection.rawValue)")
}
print("Finish Reachability")
}
}
}
La creación del objeto try? NetworkReachability()
hay que retenerla en memoria para mantener la suscripción.
La creación del objeto let notifier = try? reachability.startNotifier()
se debe realizar en una nueva Task
, ya que a la hora de realizar el for-await-in
la Task
se quedará en ejecución y no terminará hasta que finalizemos el loop manualmente o a través de la liberación de memoria.