Cuando desarrollamos una app para móviles, más tarde o temprano vamos a conectarnos a alguna fuente de datos externa, normalmente un API. Y para ello, normalmente, utilizamos alguna librería, la más común en iOS suele ser Alamofire.
Hoy seguiremos con nuestra app para películas, y lo que haremos será leer datos de un web service proporcionado por un API.
Antes de ponernos en faena, tengo que hacer dos trabajos previos. El primero será crear una Vista para el detalle de una película. Así que me crearé un archivo MovieView, y siguiendo con nuestra arquitectura MVVM, también crearé un archivo MovieViewModel.
En el segundo trabajo previo me voy a anticipar a lo que va a pasar cuando llamemos a nuestra API desde el ViewModel y crearé Mocks de nuestros ViewModel que llamen datos mock cuando se pintan las previews de nuestras vistas. De este modo, nuestras previews no dependen de fuentes de datos reales, que más adelante se puede llegar a complicar.
Veamos como se hace esto en la pantalla de HomeView (en la de MovieView será igual, podréis revisarlo en el repositorio).
Primero creamos en el archivo HomeViewModel una clase que extienda de HomeViewModel. Lo creamos dentro de un bloque if DEBUG para que a la hora de publicar la aplicación, este código no se llegue a ejecutar.
#if DEBUG
class MockHomeViewModel: HomeViewModel {
override func loadMovies() {
self.movies = moviesTest
}
}
#endif
Ahora en nuestra vista HomeView haremos un par de cambios en nuestra preview.
Metemos nuestra vista en una variable, instanciamos nuestro nuevo ViewModel mock y se lo asignamos, y finalmente retornamos la vista. Acordaros de incluirlo en un if DEBUG, si no, a la hora de publicar os dará error por utilizar el ViewModel que si que está dentro de un if DEBUG.
#if DEBUG
struct HomeView_Previews: PreviewProvider {
static var previews: some View {
var view = HomeView()
let viewModel = MockHomeViewModel()
view.viewModel = viewModel
return view
}
}
#endif
De este modo, cuando se pinten nuestra vistas en modo preview se usarán los datos mock que hemos definido. Así, tendremos en todo momento controlado lo que queremos mostrar en cada preview.
Vamos ahora si con la parte que interesa, llamar a datos desde una API.
Empezaremos leyendo el listado de películas, para ello, con una herramienta tipo Postman podemos ver la estructura de la respuesta, y como debemos hacer la petición.
Aquí os dejo la petición y un ejemplo de resultado en formato json de la petición al listado de películas. Acordaros de utilizar vuestra ApiKey.
https://api.themoviedb.org/3/movie/popular?api_key=HERE_YOUR_API_KEY
{
"page": 1,
"results": [
{
"adult": false,
"backdrop_path": "/7ZO9yoEU2fAHKhmJWfAc2QIPWJg.jpg",
"genre_ids": [
28,
878,
53
],
"id": 766507,
"original_language": "en",
"original_title": "Prey",
"overview": "When danger threatens her camp, the fierce and highly skilled Comanche warrior Naru sets out to protect her people. But the prey she stalks turns out to be a highly evolved alien predator with a technically advanced arsenal.",
"popularity": 7796.888,
"poster_path": "/ujr5pztc1oitbe7ViMUOilFaJ7s.jpg",
"release_date": "2022-08-02",
"title": "Prey",
"video": false,
"vote_average": 8.1,
"vote_count": 2730
}
],
"total_pages": 34792,
"total_results": 695830
}
Vemos que tenemos un objeto principal que nos da datos de paginación del listado, y en la propiedad results tendremos un listado de objetos con datos de películas.
Cuando leemos con Alamofire un API lo que se suele hacer es convertir el resultado, un archivo json, a estructuras definidas en nuestra app. Para ello hacemos uso de Codable que nos permite definir estas estructuras en Swift.
Vamos a definir estos objetos. Primero, vamos con los datos en si de una película. Para hacerlo sencillo definiremos unas pocas propiedades. Así también veremos que no es necesario dar de alta todas las propiedades, solo las que nos interesan. Así que nos vamos a quedar de momento solo con 4 propiedades.
{
"id": 766507,
"overview": "When danger threatens ...",
"poster_path": "/ujr5pztc1oitbe7ViMUOilFaJ7s.jpg",
"title": "Prey"
}
Y pasándolo a una estructura en Swift nos quedará así. En un principio, y por simplicidad no hay ninguna propiedad Optional, pero si nuestra API nos devuelve algún null, solamente debemos marcar la que corresponde como Optional.
struct ApiObjectMovie: Codable {
var id: Int64
var original_title: String
var overview: String
var poster_path: String
}
Personalmente, y debido a mi experiencia previa, siempre defino como Optional todas las propiedades de mis objetos de API, esto es porque como es un elemento externo, no puedo asegurar si va a ser nulo o no. Da un poco más de trabajo, pero evitamos errores.
Seguimos con la siguiente estructura, la que corresponde al paginador, así que nuevo archivo, y veámoslo en detalle.
struct ApiObjectPaginator<T: Codable>: Codable {
var page: Int
var total_pages: Int
var total_results: Int64
var results: [T]
}
Aquí vemos cuatro propiedades, las tres primeras no tienen dificultad, mapean datos de tipo Int. Así que nos centraremos en la última «results». Si nos fijamos, es un array de objetos tipo «T». Pero que es esto? Si no hemos definido ningún struct o class que se llame así. Esta es una forma muy sencilla y potente de utilizar clases genéricas y no casarnos con una clase concreta. De hecho, se llama «generic».
Simplemente, al definir nuestra struct (o class), después del nombre, entre los símbolos de < y >, le indicamos que estaremos usando un genérico al que llamaremos T y además, en este caso, debe implementar Codable, en caso contrario dará error.
Y como se usa? Pues muy sencillo, cuando queramos definir un paginador, simplemente lo haremos así:
ApiObjectPaginator<ApiObjectMovie>
De este modo, estaremos diciendo que tenemos un paginador con un listado results de tipo ApiObjectMovie. Esto finalmente nos permitirá reusar esta clase, y no tener que crear una nueva para cada listado paginado. Sigamos.
Ahora que ya tenemos nuestras struct para los objetos del API definidos, veamos como hacer la petición con Alamofire.
Creamos un archivo ApiService y dentro de este empezamos con la clase desde donde haremos las peticiones.
class ApiRestClient {
private let urlBase: String = "https://api.themoviedb.org/3/movie/"
private let APIKEY_NAME: String = "api_key"
private let configuration: Configuration
public init(configuration: Configuration) {
self.configuration = configuration
}
}
Definimos varias propiedades que necesitaremos más adelante, además le pasamos por el constructor el objeto Configuration que ya vimos en otro artículo. Este se encargará de darnos el valor correcto de nuestra Api Key.
Ahora definimos un par de estructuras para los errores.
struct ApiRestError: Error {
let error: Error
let serverError: ServerError?
}
struct ServerError: Codable, Error {
var status: String
var message: String
}
struct ConfigError: Error {
var code: Int
var message: String
}
Por un lado tenemos ApiRestError, que nos dará información en caso de que algo falle. Aquí tenemos un error para el cliente, por ejemplo si no tenemos conexión, y otro para errores que nos devuelva el API, por ejemplo si tenemos error 500.
Siguiente, creamos un protocolo donde daremos de alta cada una de las peticiones al API. En este caso, vamos a dar de alta dos, una para el listado y otra para leer el detalle de una película.
protocol ApiServiceProtocol {
func fetchPopularMovies() -> AnyPublisher<DataResponse<ApiObjectPaginator<ApiObjectMovie>, ApiRestError>, Never>
func fetchMovie(movieId: String) -> AnyPublisher<DataResponse<ApiObjectMovie, ApiRestError>, Never>
}
Como veis, nuestros métodos, deben devolver un Publisher, esto forma parte de Combine, el cual es una estructura que nos permitirá observar el resultado de la petición, y a la cuales nos suscribiremos finalmente desde nuestros ViewModel, pero eso lo veremos más adelante.
Ahora vamos a ver como implementar la petición para el listado de películas. Creamos una extensión de ApiRestClient que extienda de ApiServiceProtocol.
extension ApiRestClient: ApiServiceProtocol {
func fetchPopularMovies() -> AnyPublisher<DataResponse<ApiObjectPaginator<ApiObjectMovie>, ApiRestError>, Never> {
let url = URL(string: "\(urlBase)popular?\(APIKEY_NAME)=\(configuration.apiKey ?? "")")!
return AF.request(url, method: .get)
.validate()
.publishDecodable(type: ApiObjectPaginator<ApiObjectMovie>.self)
.map { response in
response.mapError { error in
let serverError = response.data.flatMap { try? JSONDecoder().decode(ServerError.self, from: $0)}
return ApiRestError(error: error, serverError: serverError)
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
Creamos primero la url de nuestra petición. Estamos asumiendo que se creará bien, luego mejoraremos esta parte. Seguidamente llamamos con Alamofire a request, pasándole la url y diciéndole que se trata de un get.
Importante, debemos validar además la respuesta con validate, de otro modo, Alamofire intentará decodificar la respuesta a la struct que le indicamos sin saber si la petición fue o no exitosa.
Con la ayuda de mapError revisaremos si tenemos algún error, y en caso afirmativo crearemos el error correspondiente.
Por último indicamos en que hilo vamos a publicar el resultado y creamos nuestro publisher.
El código para leer una película es muy similar, solamente debemos cambiar como se construye la URL y en el método publishDecodable indicar que tipo de struct esperamos.
.publishDecodable(type: ApiObjectMovie.self)
De hecho, al ser tan parecido, después veremos como mejorar esta parte.
Vamos primero hasta nuestro ViewModel y veamos como utilizar todo esto.
Empezamos instanciando todo lo que necesitamos. En este punto no me meteré a usar inyección de dependencias para no alargar demasiado, pero en el futuro trataremos este punto.
let apiRestClient: ApiRestClient
let configuration: Configuration
private var cancellableSet: Set<AnyCancellable> = []
init() {
self.configuration = Configuration()
self.apiRestClient = ApiRestClient(configuration: configuration)
}
Y ahora en loadMovies, cambiamos nuestra fuente de datos. Hasta ahora estábamos leyendo datos mock, pero ahora vamos a leer datos reales.
func loadMovies() {
apiRestClient.fetchPopularMovies()
.sink { (dataResponse) in
if dataResponse.error != nil {
print(dataResponse.error.debugDescription)
} else {
self.movies = dataResponse.value?.results.compactMap({ apiItem in
Movie(id: String(apiItem.id),
imdbId: nil,
title: apiItem.original_title,
overView: apiItem.overview,
posterPath: apiItem.poster_path)
}) ?? []
}
}
.store(in: &cancellableSet)
}
Con nuestra clase apiRestClient llamamos al método que nos interesa, listado de películas en este caso. Con sink podemos ir recibiendo los valores que nos llegan desde nuestro Publisher.
Aquí revisamos si la respuesta es un error o no, y en caso de no serlo, lo que haremos será convertir con compactMap cada uno de los objetos ApiObjectMovie a uno Movie, que es lo que espera recibir nuestra vista.
Y finalmente el resultado se asigna a nuestro listado de pel´ículas movies que ya se estaba observando desde la vista.
Y hasta aquí, si compilamos y ejecutamos, estaremos leyendo nuestro listado de películas. Pero… hay un par de puntos que dijimos que se podían mejorar.
El primero es como se crea la url para cada petición en AiRestClient.
Estábamos haciendo esto. Lo cual podría fallar.
let url = URL(string: "\(urlBase)popular?\(APIKEY_NAME)=\(configuration.apiKey ?? "")")!
Así que vamos a verificar con un guard que estos valores no sean nulos.
guard let apiKey = configuration.apiKey,
let url = URL(string: "\(urlBase)popular?\(APIKEY_NAME)=\(apiKey)") else {
return emptyPublisher(error: ConfigError(code: 555, message: "No URL defined"))
}
Esto nos obliga a devolver en el else de nuestro guard un Publisher, así que crearemos un método que nos ayude con esto.
func emptyPublisher<T: Codable>(error: ConfigError) -> AnyPublisher<DataResponse<T, ApiRestError>, Never> {
return Just<DataResponse<T, ApiRestError>>(
DataResponse(request: nil,
response: nil,
data: nil,
metrics: nil,
serializationDuration: 0,
result: .failure(ApiRestError(error: error, serverError: nil))
)
)
.eraseToAnyPublisher()
}
Y debemos crear un nuevo tipo de error. Con la propiedad code podremos verificar de que tipo de error personalizado se trata.
struct ConfigError: Error {
var code: Int
var message: String
}
El siguiente punto a mejorar es la parte de la petición a Alamofire que estamos repitiendo.
Para ello nos llevamos toda esto a un nuevo método que extienda de DataRequest, y gracias a los generics podremos hacer que reciba cualquier tipo de struct que implemente Codable.
extension DataRequest {
func proccessResponse<T: Codable>(type: T.Type) -> AnyPublisher<DataResponse<T, ApiRestError>, Never> {
self
.validate()
.publishDecodable(type: type.self)
.map { response in
response.mapError { error in
let serverError = response.data.flatMap { try? JSONDecoder().decode(ServerError.self, from: $0)}
return ApiRestError(error: error, serverError: serverError)
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
Y desde cada uno de los métodos que hace una petición con Alamofire debemos hacer lo siguiente.
return AF.request(url, method: .get)
.proccessResponse(type: ApiObjectPaginator<ApiObjectMovie>.self)
Como veis, en un par de líneas ahora podemos hacer una petición, y centralizamos la forma en la que procesamos la respuesta.
Y con esto ya hemos empezado a leer datos reales desde un API. Si recordáis, nuestra aplicación, además, debe almacenar estos datos en una base de datos local, para tener de respaldo cuando no tengamos conectividad. Pero eso lo veremos en otro artículo.
Finalmente, os dejo el enlace al repositorio como siempre donde podréis revisar también como implementé la llamada para leer el detalle de una película https://github.com/3pies/moviesios