Consumiendo Google desde iOS (3) – La Parte Divertida por @vermicida

Autor: | Última modificación: 3 de noviembre de 2022 | Tiempo de Lectura: 9 minutos
Temas en este post: ,

Bueno, por fin ha llegado el momento de escribir algo de código en este tutorial de Google desde iOS (3). Lo que vamos a hacer durante los siguientes párrafos es plasmar la teoría del capítulo anterior en nuestra aplicación iOS. Vamos a encapsular toda la lógica del flujo de autorización en una sola clase, para que luego podamos reutilizarla en cuantos proyectos necesitemos.

Los otros capítulos son:

Vamos abriendo Xcode y recuperando el proyecto que creamos en el primer capítulo.

La parte divertida

Consumir recursos de Google se va a traducir en hacer peticiones HTTP con los verbos estándar que todos conocemos: GET, POST, PUT y DELETE. Puede que alguna API exponga servicios que acepten otros verbos, pero en la mayoría de los casos serán estos cuatro que hemos comentado. Vamos a delegar las peticiones HTTP al framework AFNetworking; podríamos hacerlo nosotros mismos usando NSURLConnection + NSURLConnectionDelegate, pero se me antojaba algo tedioso tener que centralizar todas las respuestas HTTP en el mensaje connectionDidFinishLoading. Yo, que me paso el día programando Javascript, estoy muy acostumbrado a pasar funciones como parámetro a otras funciones para usarlas como callbacks, así que aprecio mucho los bloques de Objective-C. Y la librería AFNetworking ya está orientada a bloques, así que la usaremos para facilitarnos el desarrollo. Yo la he referenciado en el proyecto vía Cocoapods, pero vosotros podéis hacerlo como más os guste. Por si acaso, dejo enlace al tutorial que Miguel Díaz Rubio nos regaló hace unos meses sobre Cocoapods.

En este punto ya debemos tener nuestro proyecto con AFNetworking añadido. ¡Vamos al lío!

Creamos la famosa clase de autorización OAuth 2.0. En mi caso la he llamado DHGoogleOauth, pero vosotros le podéis poner el nombre que más cómodo os resulte. Además, importante, va a heredar de UIWebView; esto es así porque, como ya hemos comentado, vamos a tener que presentar al usuario una pantalla de autorización, que no es más que una URL de Google, y tendremos que navegarla. También, para no perder las costumbres, usaremos el delegado de turno. De partida, la definición de la clase, nuestro documento .h,  tendrá este aspecto:

#import <UIKit/UIKit.h>

@interface DHGoogleOauth : UIWebView <UIWebViewDelegate>

@end

Lo ideal es que la clase DHGoogleOauth sea capaz de avisar a aquellos que se apoyen en ella de ciertos eventos; por ejemplo, cuando ocurre un error durante la autorización. Vamos a definir un protocolo con estos eventos, para poder delegar en otros el tratamiento de los mismos. Justo encima de nuestra @interface, escribimos:

@protocol DHGoogleOauthDelegate

- (void)authorizationGranted;
- (void)authorizationRevoked;
- (void)errorOccured:(NSError *)error;

@end

Toca definir las propiedades y mensajes que estarán expuestas en nuestro .h. Vamos a necesitar un inicializador de instancia, un mensaje para iniciar el proceso de autorización, y otro más para la acción contraria, revocar una autorización activa. Puede que algunos de los parámetros del inicializador os suenen del primer capítulo de este tutorial; si es así, vamos por buen camino. También, creamos una propiedad de tipo DHGoogleOauthDelegate. Dentro de nuestra @interface:

@property (nonatomic, strong) id<DHGoogleOauthDelegate> googleOauthDelegate;

- (id)initWithClientId:(NSString *)clientId
       andClientSecret:(NSString *)clientSecret
        andRedirectUri:(NSString *)redirectUri
             forScopes:(NSArray *)scopes
          inParentView:(UIView *)parentView;
- (void)grantAuthorization;
- (void)revokeAuthorization;

Nos movemos al documento de implementación, el .m. Definimos algunas propiedades, constantes y mensajes más, y ya nos metemos de lleno con la implementación de los mismos. No os preocupéis si no entendéis qué hace cada cosa; lo iremos explicando todo en su debido momento. Previo a @implementation:

#include <AFNetworking/AFNetworking.h>

NSString *const endpointForAuthorization = @"https://accounts.google.com/o/oauth2/auth";
NSString *const endpointForRevocation = @"https://accounts.google.com/o/oauth2/revoke";
NSString *const endpointForToken = @"https://accounts.google.com/o/oauth2/token";

@interface DHGoogleOauth()

@property (nonatomic, strong) NSString *clientId;
@property (nonatomic, strong) NSString *clientSecret;
@property (nonatomic, strong) NSString *redirectUri;
@property (nonatomic, strong) NSArray *scopes;
@property (nonatomic, strong) UIView *parentView;
@property (nonatomic, strong) NSDictionary *token;
@property (nonatomic, strong) NSString *tokenDocumentPath;

- (NSString *)generatePayload:(NSDictionary *)parameters;
- (void)showAuthorizationView;
- (void)requestTokenWithCode:(NSString *)code
               thenOnSuccess:(RequestSuccess)successBlock
                 thenOnError:(RequestError)errorBlock;
- (void)refreshTokenThenOnSuccess:(RequestSuccess)successBlock
                      thenOnError:(RequestError)errorBlock;
- (BOOL)tokenHasExpired;
- (void)writeTokenDocument:(NSDictionary *)token;
- (NSDictionary *)readTokenDocument;

@end

¿Qué acabamos de hacer? Hemos referenciado el framework AFNetworking, ya que lo vamos a necesitar en breve. Hemos creado tres constantes que apuntan a los endpoints que vamos a usar para el flujo de autorización. Los endpoints no son más que servicios que sirven de punto de entrada a las distintas APIs de Google; en este caso, tenemos un endpoint para la autorización, otro para la revocación, y un tercero para la solicitud del token de acceso. Y también, varias propiedades y mensajes de apoyo.

Seguro que os habréis percatado ya de un par de parámetros cuyos tipos se antojan desconocidos. Lo son, pero le vamos a poner remedio ahora mismo. Uno de ellos, RequestSuccess, es un bloque que acepta en un objeto id las respuestas satisfactorias -códigos 2xx- de peticiones HTTP. El otro, RequestError, también es un bloque y acepta en un puntero a un objeto NSError las respuestas fallidas -códigos 4xx y 5xx- de peticiones HTTP. Para aquellos interesados, aquí os dejo la definición de los códigos de estado de la W3C. Damos un salto rápido al .h, dentro @interface, para definirlos:

typedef void (^ RequestSuccess)(id);
typedef void (^ RequestError)(NSError *);

Vamos a ir implementando todos los mensajes que hemos definido. Lo siguiente va todo dentro de @implementation. Comenzamos por el inicializador; su misión no es otra que poner a salvo los datos que nos facilitan, y establecer la ruta y nombre del documento donde almacenaremos el token de acceso.

- (id)initWithClientId:(NSString *)clientId
       andClientSecret:(NSString *)clientSecret
        andRedirectUri:(NSString *)redirectUri
             forScopes:(NSArray *)scopes
          inParentView:(UIView *)parentView {

    if ( self = [super initWithFrame:[parentView frame]] ) {

        [self setClientId:clientId];
        [self setClientSecret:clientSecret];
        [self setRedirectUri:redirectUri];
        [self setParentView:parentView];
        [self setScopes:scopes];

        NSString *directory = [NSSearchPathForDirectoriesInDomains( NSDocumentDirectory, NSUserDomainMask, YES ) objectAtIndex:0];
        [self setTokenDocumentPath:[directory stringByAppendingString:@"/googletoken"]];
    }

    return self;
}

Continuamos con algunos mensajes de apoyo. Los acompaño de un breve comentario.

Yo soy un detractor declarado de los string format kilométricos; siempre me ha dado la impresión de que ensucian el código y dificultan la lectura al desarrollador. Por eso, siempre que puedo, tiro de colecciones y hago joins o cosas por el estilo. En este caso, generatePayload concatena en una cadena de texto con formato de querystring payload los distintos pares key-value de un objeto NSDictionary:

- (NSString *)generatePayload:(NSDictionary *)parameters {

    NSMutableString *payload = [NSMutableString stringWithString:@""];

    if ( [parameters count] > 0 ) {

        for ( NSString *key in [parameters allKeys] ) {

            [payload appendFormat:@"%@=%@&", key, [parameters objectForKey:key]];
        }

        payload = [NSMutableString stringWithString:[payload substringToIndex:[payload length] - 1]];
    }

    return [payload copy];
}

El mensaje showAuthorizationView pide a Google que muestre una pantalla de autorización al usuario. Si nos fijamos en la URL que se navega, observamos lo siguiente:

  • Se ataca el endpoint de autorización.
  • client_id indica el servidor de autorización que debe gestionar el proceso; en otras palabras, el proyecto que creamos en la Cloud Console.
  • redirect_uri especifica la URL a la que se navegará una vez el usuario haya autorizado la acción. En aplicaciones instaladas, como es el caso, redirect_uri describe más un comportamiento que una dirección web. Esto lo tenemos mejor descrito en la documentación de Google que comparto al final del artículo.
  • scope es la colección de endpoints a los que nuestra aplicación solicita acceso. Por ejemplo, los endpoints de Drive en modo lectura y de Google+ en modo lectura/escritura. Cuando Google nos muestre la pantalla de autorización, nos listará, en base a estos scopes, las acciones que el usuario debe autorizar.
  • response_type indica el resultado de la autorización. Lo que hacemos especificando code es decirle a Google que, por favor, nos genere un código de autorización -¿recordáis cómo empezaba el flujo?-.
- (void)showAuthorizationView {

    NSDictionary *parameters = @{ @"scope" : [[self scopes] componentsJoinedByString:@"+"],
                                  @"redirect_uri" : [self redirectUri],
                                  @"client_id" : [self clientId],
                                  @"response_type" : @"code" };

    NSString *authorizationUri = [NSString stringWithFormat:@"%@?%@", endpointForAuthorization, [self generatePayload:parameters]];

    [self setDelegate:self];
    [self setScalesPageToFit:YES];
    [self setAutoresizingMask:[[self parentView] autoresizingMask]];

    [self loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:authorizationUri]]];
    [[self parentView] addSubview:self];
}

El siguiente paso del flujo de autorización lo ejecuta requestTokenWithCode:thenOnSuccess:thenOnError. Ya con el código de autorización en nuestro poder, toca intercambiarlo por un token de acceso. Le decimos a Google lo siguiente:

  • Te voy a pedir un token de acceso: POST sobre el endpoint de token.
  • Toma mi código de autorización y me generas el token a partir de él: code y grant_type.
  • El servidor de autorización -el proyecto de Cloud Console- que debes usar para verificar este tinglado es este: client_id y client_secret.
  • Y cuando termines, compórtate así: redirect_uri.

Una vez hemos obtenido el token de acceso, lo guardamos en una propiedad para tenerlo a mano, y lo persistimos en el documento que definimos en el inicializador un poco más arriba.

- (void)requestTokenWithCode:(NSString *)code
               thenOnSuccess:(RequestSuccess)successBlock
                 thenOnError:(RequestError)errorBlock {

    NSDictionary *parameters = @{ @"code" : code,
                                  @"redirect_uri" : [self redirectUri],
                                  @"client_id" : [self clientId],
                                  @"client_secret" : [self clientSecret],
                                  @"grant_type" : @"authorization_code" };

    AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];

    [manager POST:endpointForToken
       parameters:parameters
          success:^(AFHTTPRequestOperation *operation, id responseObject) {

              [self setToken:responseObject];
              [self writeTokenDocument:responseObject];

              successBlock( responseObject );
          }
          failure:^(AFHTTPRequestOperation *operation, NSError *error) {

              errorBlock( error );
          }];
}

Los tokens de acceso que nos genera Google suelen tener una vida de 3600 segundos, es decir, una hora. Una vez ese tiempo ha expirado, el token se vuelve inservible y es necesario refrescarlo. De eso se encarga refreshTokenThenOnSuccess:thenOnError, usando como referencia el token de refresco que obtuvimos junto al token de acceso en el paso anterior -vienen ambos especificados en el JSON de respuesta de Google-. Una vez obtenido, actualizamos el token de acceso y el tiempo de expiración del mismo -nunca se sabe- del ya obsoleto que tenemos almacenado.

- (void)refreshTokenThenOnSuccess:(RequestSuccess)successBlock
                      thenOnError:(RequestError)errorBlock {

    NSDictionary *parameters = @{ @"refresh_token" : [[self token] objectForKey:@"refresh_token"],
                                  @"client_id" : [self clientId],
                                  @"client_secret" : [self clientSecret],
                                  @"grant_type" : @"refresh_token" };

    AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];

    [manager POST:endpointForToken
       parameters:parameters
          success:^(AFHTTPRequestOperation *operation, id responseObject) {

              [[self token] setValue:[responseObject objectForKey:@"access_token"]
                                                           forKey:@"access_token"];
              [[self token] setValue:[responseObject objectForKey:@"expires_in"]
                                                           forKey:@"expires_in"];

              [self writeTokenDocument:token];

              successBlock( responseObject );
          }
          failure:^(AFHTTPRequestOperation *operation, NSError *error) {

              errorBlock( error );
          }];
}

Este señor, tokenHasExpired, comprueba si el token de acceso que tenemos almacenado sigue siendo válido.

- (BOOL)tokenHasExpired {

    BOOL expired;
    NSError *error = nil;
    NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:[self tokenDocumentPath]
                                                                                error:&error];

    if ( error ) {

        expired = YES;
    }
    else {

        NSDate *creation = [attributes fileCreationDate];
        expired = [[NSDate date] timeIntervalSinceDate:creation] >= [[[self token] objectForKey:@"expires_in"] intValue];
    }

    return expired;
}

Lectura y escritura del token de acceso en un documento físico. No hay mucho que explicar en este punto.

- (void)writeTokenDocument:(NSDictionary *)token {

    [token writeToFile:[self tokenDocumentPath]
            atomically:YES];
}

- (NSDictionary *)readTokenDocument {

    return [NSDictionary dictionaryWithContentsOfFile:[self tokenDocumentPath]];
}

Y hasta aquí los mensajes de apoyo.

Vamos a implementar el mensaje webViewDidFinishLoad del protocolo UIWebViewDelegate. Este mensaje es llamado cada vez que nuestra UIWebView termina de cargar una URL que ha navegado. Vamos a considerar dos casos y para ello, tal como recomienda Google, prestamos atención al título de la página navegada.

Por un lado, podemos obtener Success code=x, donde x es el código de autorización. Esto ocurre cuando el usuario acepta la autorización que se le propone. En tal caso, avisamos al usuario que estamos obteniendo el token de acceso y procedemos al intercambio del código de autorización por éste. Cuando se obtiene, cerramos la vista del navegador y avisamos a nuestros delegados de que el usuario ha autorizado correctamente la acción.

Por otro lado, podemos obtener access_denied, que indica que el usuario ha cancelado la autorización. Si esto ocurre, nos limitamos a cerrar la vista del navegador.

- (void)webViewDidFinishLoad:(UIWebView *)webView {

    NSString *title = [webView stringByEvaluatingJavaScriptFromString:@"document.title"];

    if ( [title rangeOfString:@"Success"].location != NSNotFound ) {

        NSString *code = [[title componentsSeparatedByString:@"="] objectAtIndex:1];

        [self loadHTMLString:@"<html><head><title>Please wait</title></head><body><h1>Retrieving the token...</h1></body></html>"
                     baseURL:[NSURL fileURLWithPath:[[NSBundle mainBundle] bundlePath]]];

        RequestSuccess onSuccess = ^(id response) {

            [webView removeFromSuperview];
            [[self googleOauthDelegate] authorizationGranted];
        };

        RequestError onError = ^(NSError *error) {

            [[self googleOauthDelegate] errorOccured:error];
        };

        [self requestTokenWithCode:code
                     thenOnSuccess:onSuccess
                       thenOnError:onError];
    }
    else if ( [title rangeOfString:@"access_denied"].location != NSNotFound ) {

        [webView removeFromSuperview];
    }
}

Solo quedan un par de pasos más para estar listos. ¡Un último esfuerzo!

El mensaje grantAuthorization es nuestro orquestador. Es quien ejecuta el flujo de autorización OAuth 2.0 que comentamos en el capítulo anterior, y también quien gestiona el almacenaje y refresco del token de acceso. Este es el principal mensaje de esta clase y el que otras deben llamar para autorizar el acceso a las distintas APIs de Google. El código es muy sencillo, pero hacemos un breve repaso:

  • Si no tenemos un token almacenado, significa que el usuario no ha autorizado nada aún, por lo que iniciamos el flujo de autorización.
  • En caso de que sí exista un token almacenado, lo leemos y verificamos si aún es válido.
  • Si el token de acceso ha expirado, se procede a su refresco.
  • Por el contrario, si no ha expirado, se avisa a los delegados que todo está correcto.
- (void)grantAuthorization {

    if ( [[NSFileManager defaultManager] fileExistsAtPath:[self tokenDocumentPath]] ) {

        [self setToken:[self readTokenDocument]];

        if ( [self tokenHasExpired] ) {

            RequestSuccess onSuccess = ^(id response) {
                [[self googleOauthDelegate] authorizationGranted];
            };

            RequestError onError = ^(NSError *error) {
                [[self googleOauthDelegate] errorOccured:error];
            };

            [self refreshTokenThenOnSuccess:onSuccess
                                thenOnError:onError];
        }
        else {

            [[self googleOauthDelegate] authorizationGranted];
        }
    }
    else {

        [self showAuthorizationView];
    }
}

Por último, revokeAuthorization solicita a Google la revocación del token de acceso que actualmente tenemos vigente. Además, elimina tanto el token que tenemos almacenado en la propiedad como el que tenemos almacenado en el documento. Como veis, aquí usamos el endpoint que nos quedaba en el tintero, el de revocación de token. Este mensaje es útil para aquellos casos en los que el usuario quiera desvincular su cuenta de Gmail -desautorizar- de nuestra aplicación.

- (void)revokeAuthorization {

    NSDictionary *parameters = @{ @"token" : [[self token] objectForKey:@"access_token"] };

    AFHTTPRequestOperationManager *manager = [AFHTTPRequestOperationManager manager];

    [manager POST:endpointForRevocation
       parameters:parameters
          success:^(AFHTTPRequestOperation *operation, id responseObject) {

              [self setToken:nil];
              [[NSFileManager defaultManager] removeItemAtPath:[self tokenDocumentPath]
                                                         error:nil];

              [[self googleOauthDelegate] authorizationRevoked];
          }
          failure:^(AFHTTPRequestOperation *operation, NSError *error) {

              [[self googleOauthDelegate] errorOccured:error];
          }];
}

Hasta aquí esta parte del tutorial. Más información sobre los servicios vistos, sus parámetros y cómo interpretar sus respuestas: Using OAuth 2.0 for Installed Applications.

Acabamos de programar una buena pieza código. Por supuesto, es susceptible de mejoras y optimizaciones varias, pero nos cubre con creces el propósito. Lo que queremos aprender con este tutorial es cómo una aplicación iOS puede hablar con los distintos servicios de Google; el código super ultra mega chachi guay no es el foco de este tutorial, pero agradezco, y mucho, cualquier tipo de comentario en este sentido.

Lo próximo, un hola mundo con la API de Google Calendar.

[email protected]

¿Trabajo? Aprende a programar y consíguelo.

¡No te pierdas la próxima edición del Aprende a Programar desde Cero Full Stack Jr. Bootcamp!

 

Prepárate en 4 meses, aprende las últimas tecnologías y consigue trabajo desde ya. 

 

Solo en España hay más de 120.400 puestos tech sin cubrir, y con un sueldo 11.000€ por encima de la media nacional. ¡Es tu momento!

 

🗓️ Próxima edición: 13 de febrero

 

Reserva tu plaza descubre las becas disponibles.