nyroBlog
Bannière NyroBlog, par Nyro
Image par Nyro - ?

Tag : Programmation


Connexions OAuth Multiple avec Symfony 2.3

Voici un long tutorial pour Symfony 2.3 permettant d'ajouter des boutons dec onnexion à des services proposant de l'Oauth (comme Facebook, twitter, google, etc..)

Il existe un très bon Bundle, HWIOAuthBundle qui a toutes les fonctionnalités de connexion à OAuth, mais qui manque cruellement de documentations claires.

Ce tutorial part du principe que vous avez déjà en place :

  • Un firewall dans lequel on peut se connecter via un formulaire de connexion (form_login)
  • Un formulaire d'inscription
  • Les utilisateurs sont enregistrés en base de données (via une entity, nommé User dans ce tuto)
  • La gestion des roles et sécurité restera exactement la même

 

Les fonctionnalités que nosu allons mettre en place vont se greffer à l'existant. Toute la sécurité, gestion d'accès et autre reste à mettre en place (ou le sont peut-être déjà)
L'ajout de la fonctionnalité Connexion Oauth permettra :

  • Pré-remplir le formulaire d'inscription si l'utilisateur n'existe pas
  • Connexion en un seul clic via un service externe
  • Ajout d'un service externe pour un compte déjà existant : Ainsi un utilisateur pourra utiliser plusieurs boutons de connexion externe pour se connecter au même compte de votre site



Installation de HWIOAuthBundle

La première étape consiste donc à installer HWIOAuthBundle
Dans le composer.json, il faut ajouter la ligne suivante de le require:

"hwi/oauth-bundle": "0.3.*@dev"

Un update, et on ajoute le Bundle dans app/AppKernel.php :

public function registerBundles()
{
    $bundles = array(
        // ...
        new HWI\Bundle\OAuthBundle\HWIOAuthBundle(),
    );
}

Jusque là, rien d'anormal. Si on essaie de recharger Symfony à ce moment là, on aura une erreur car le Bundle n'est pas configuré.

 

Configuration du bundle

Donc dans le fichier config.yml, on peut ajouter cette configuration :

#HWIOAuthBundle
hwi_oauth:
    firewall_name: oauth
    resource_owners:
        facebook:
            type: facebook
            client_id: %facebook_client_id%
            client_secret: %facebook_client_secret%
            scope: email
            infos_url:     "https://graph.facebook.com/me?fields=username,name,email,picture.type(large)"
            paths:
                email:          email
                profilepicture: picture.data.url
            options:
                display: popup #dialog is optimized for popup window
        twitter:
            type: twitter
            client_id: %twitter_client_id%
            client_secret: %twitter_client_secret%
            paths:
                profilepicture: profile_image_url
        google:
            type: google
            client_id: %google_client_id%
            client_secret: %google_client_secret%
            scope: "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"
            paths:
                email:           email
                profilepicture:  picture

Les services facebook, twitter et google sont paramétré pour se connecter, mais aussi récupérer l'email et l'avatar de l'utilisateur

Le paramètre firewall_name est important ; c'est le nom que l'on va donner au firewall spécifique à la connexion oAuth

Vous aurez noté qu'on utilise des paramètres pour les client_id et client_secret. A vous de les placer correctement dans votre fichier parameters.
Les options pour chaque app sur les services externes et la récupération de ces infos diffèrent selon les services, mais on s'y retrouve assez facilement.

 

Configuration du firewall

Il est temps d'ajouter ce firewall.
Notre but est de ne rien touché aux firewall acuels et de créer un espace spécial pour tout ce qui concerne le bundle que l'on vient d'ajouter.
Dans votre fichier app/config/security.yml, ajoter les informations suivantes :

security:
    # Probably other stuff here

    providers:
		# Probably other providers here
        oauth:
            id: my_oauth_members

    firewalls:
		# Probably other firewalls here

        oauth:
            pattern:    ˆ/oauth/.*
            anonymous: ~
            provider: oauth
            oauth:
                resource_owners:
                    facebook: "/oauth/login/check-facebook"
                    twitter: "/oauth/login/check-twitter"
                    google: "/oauth/login/check-google"
                login_path: /oauth/login
                failure_path: /oauth/login
                check_path: /oauth/login_check
                default_target_path: /oauth/target
                oauth_user_provider:
                    service: my_oauth_members

    access_control:
		# Probably other access_control here
        - { path: ˆ/oauth/target, roles: ROLE_OAUTH_USER }
        - { path: ˆ/oauth/, roles: IS_AUTHENTICATED_ANONYMOUSLY }
		# Probably other access_control here

A la vue de ce fichier, vous aurez compris que toutes les URLs commenceront par oauth.
Vous pouvez évidemment changer cela pour ce que vous voulez, en changeant les fichiers routings.
Soyez sûr que l'URL /oauthRegister est accessible derrière votre firewall déjà existant, en étant connecté ou non. C'est cette URL qui fera le pont entre les deux firewalls.

 

Configuration du routing

Pour mettre en place le routing, il suffit d'importer dans un fichier routing extsiant un autre routing, avec le préfixe oauth. On ajouter aussi la route pour le oauthRegister

oauth_register:
    pattern: /oauthRegister
    defaults: { _controller: AcmeMainBundle:Security:oauthRegister }
my_oauth:
    resource: "@AcmeMainBundle/Resources/config/routingSecurityOAuth.yml"
    prefix:   /oauth/

Et le fichier routingSecurityOAuth.yml :

hwi_oauth_login:
    resource: "@HWIOAuthBundle/Resources/config/routing/login.xml"
    prefix:   /login
hwi_oauth_redirect:
    resource: "@HWIOAuthBundle/Resources/config/routing/redirect.xml"
    prefix:   /connect
facebook_login:
    pattern: /login/check-facebook
twitter_login:
    pattern: /login/check-twitter
google_login:
    pattern: /login/check-google
oauth_target:
    pattern: /target
    defaults: { _controller: AcmeMainBundle:Security:oauthTarget }

On commence par importer les routes du bundle, avec des préfixes spécifiques. Pour chaque service, on doit créé une route avec seulement son pattern. C'est le bundle qui intercepttera ces URLs pour réaliser les actions nécessaires. La route target permettra de recevoir les infos de l'utilisateur connecté, puis de l'envoyer vers oauthRegister et faire le pont avec l'autre firewall.

 

Configuration de l'user provider (service)

Dans le firewall, vous avez probablement remarqué que l'on a configuré le provider my_oauth_members. Il est temps de paramétrer ce provider (avant de le coder), dans un fichier services.yml. :

parameters:
    acme_main.oauth_members.class: Acme\MainBundle\Services\OAuthMembersService

services:
    my_oauth_members:
        class: %acme_main.oauth_members.class%

 

Classe OAuthUser

La classe OAuthUser sera l'objet utilisé par Symfony pour connecter l'utilisateur après l'autorisation sur un service externe. Dans notre cas, cet objet est très simple et permet simplement de conserver toutes les informations de l'utilisateur afin de les passer à oauthRegister ensuite.

// Acme/MainBundle/Security/OAuthUser.php
<?php 

namespace Acme\MainBundle\Security;

use HWI\Bundle\OAuthBundle\Security\Core\User\OAuthUser as BaseOAuthUser;
use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface;

class OAuthUser extends BaseOAuthUser {
	
	protected $data;
	
	public function __construct(UserResponseInterface $response) {
		parent::__construct($response->getUsername());
		$this->data = array(
			'provider'=>$response->getResourceOwner()->getName(),
			'providerId'=>$response->getUsername()
		);
		$vars = array(
			'nickname',
			'realname',
			'email',
			'profilePicture',
			'accessToken',
			'refreshToken',
			'tokenSecret',
			'expiresIn',
		);
		foreach($vars as $v) {
			$fct = 'get'.ucfirst($v);
			$this->data[$v] = $response->$fct();
		}
	}
	
	public function getData() {
		return $this->data;
	}
	
	/**
     * {@inheritDoc}
     */
    public function getRoles() {
        return array('ROLE_OAUTH_USER');
    }

}

 

Classe OAuthMembersService

Cette classe est l'user provider de notre firewall est n'est utilisé que lorsque l'utilisateur se connecte depuis un service externe. Les autres fonctions sont alors inutile:

// Acme/MainBundle/Services/OAuthMembersService.php
<?php 

namespace Acme\MainBundle\Services;

use Symfony\Component\Security\Core\User\UserProviderInterface;
use HWI\Bundle\OAuthBundle\Security\Core\User\OAuthAwareUserProviderInterface;
use HWI\Bundle\OAuthBundle\OAuth\Response\UserResponseInterface;
use Acme\MainBundle\Security\OAuthUser;

class OAuthMembersService implements UserProviderInterface, OAuthAwareUserProviderInterface {

    public function loadUserByUsername($username) {
		throw new Exception('loadByUsername not implemented');
    }
	
    public function supportsClass($class) {
		return $class === 'Acme\\MainBundle\\Security\\OAuthUser';
    }
	
	public function refreshUser(\Symfony\Component\Security\Core\User\UserInterface $user) {
		if (!$this->supportsClass(get_class($user))) {
			throw new UnsupportedUserException(sprintf('Unsupported user class "%s"', get_class($user)));
		}
		return $user;
	}
	
    public function loadUserByOAuthUserResponse(UserResponseInterface $response) {
		return new OAuthUser($response);
    }
	
}

 

Entity UserOauth

Jusqu'à maintenant, on a tout configuré, et préparé pour envoyer les requêtes oauth sur un controller AcmeMainBundle:Security. Avant de voir ce controller, il faut préparer l'entité responsable de sauvegarder les informations de l'utilisateur en base de données, afin d'être capable de le retrouver lorsqu'il se connectera à nouveau.

L'Entity UserOauth, qui à une relation vers User, la table qui contient vos utilisateurs normaux :

// Acme/MainBundle/Entity/UserOauth.php
<?php 

namespace Acme\MainBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * UserOauth
 *
 * @ORM\Table(name="user_oauth")
 * @ORM\Entity
 */
class UserOauth
{
    /**
     * @var string
     *
     * @ORM\Column(name="provider", type="string", length=250, nullable=false)
     */
    private $provider;

    /**
     * @var string
     *
     * @ORM\Column(name="provider_id", type="string", length=250, nullable=false)
     */
    private $providerId;

    /**
     * @var string
     *
     * @ORM\Column(name="nickname", type="string", length=250, nullable=true)
     */
    private $nickname;

    /**
     * @var string
     *
     * @ORM\Column(name="realname", type="string", length=250, nullable=true)
     */
    private $realname;

    /**
     * @var string
     *
     * @ORM\Column(name="email", type="string", length=250, nullable=true)
     */
    private $email;

    /**
     * @var string
     *
     * @ORM\Column(name="profile_picture", type="string", length=250, nullable=true)
     */
    private $profilePicture;

    /**
     * @var string
     *
     * @ORM\Column(name="access_token", type="string", length=250, nullable=true)
     */
    private $accessToken;

    /**
     * @var string
     *
     * @ORM\Column(name="refresh_token", type="string", length=250, nullable=true)
     */
    private $refreshToken;

    /**
     * @var string
     *
     * @ORM\Column(name="token_secret", type="string", length=250, nullable=true)
     */
    private $tokenSecret;

    /**
     * @var string
     *
     * @ORM\Column(name="expires_in", type="string", length=250, nullable=true)
     */
    private $expiresIn;

    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="bigint")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var \Acme\MainBundle\Entity\User
     *
     * @ORM\ManyToOne(targetEntity="Acme\MainBundle\Entity\User")
     * @ORM\JoinColumns({
     *   @ORM\JoinColumn(name="user_id", referencedColumnName="id")
     * })
     */
    private $user;



    /**
     * Set provider
     *
     * @param string $provider
     * @return UserOauth
     */
    public function setProvider($provider)
    {
        $this->provider = $provider;
    
        return $this;
    }

    /**
     * Get provider
     *
     * @return string 
     */
    public function getProvider()
    {
        return $this->provider;
    }

    /**
     * Set providerId
     *
     * @param string $providerId
     * @return UserOauth
     */
    public function setProviderId($providerId)
    {
        $this->providerId = $providerId;
    
        return $this;
    }

    /**
     * Get providerId
     *
     * @return string 
     */
    public function getProviderId()
    {
        return $this->providerId;
    }

    /**
     * Set token
     *
     * @param string $token
     * @return UserOauth
     */
    public function setToken($token)
    {
        $this->token = $token;
    
        return $this;
    }

    /**
     * Get token
     *
     * @return string 
     */
    public function getToken()
    {
        return $this->token;
    }

    /**
     * Set nickname
     *
     * @param string $nickname
     * @return UserOauth
     */
    public function setNickname($nickname)
    {
        $this->nickname = $nickname;
    
        return $this;
    }

    /**
     * Get nickname
     *
     * @return string 
     */
    public function getNickname()
    {
        return $this->nickname;
    }

    /**
     * Set realname
     *
     * @param string $realname
     * @return UserOauth
     */
    public function setRealname($realname)
    {
        $this->realname = $realname;
    
        return $this;
    }

    /**
     * Get realname
     *
     * @return string 
     */
    public function getRealname()
    {
        return $this->realname;
    }

    /**
     * Set email
     *
     * @param string $email
     * @return UserOauth
     */
    public function setEmail($email)
    {
        $this->email = $email;
    
        return $this;
    }

    /**
     * Get email
     *
     * @return string 
     */
    public function getEmail()
    {
        return $this->email;
    }

    /**
     * Set profilePicture
     *
     * @param string $profilePicture
     * @return UserOauth
     */
    public function setProfilePicture($profilePicture)
    {
        $this->profilePicture = $profilePicture;
    
        return $this;
    }

    /**
     * Get profilePicture
     *
     * @return string 
     */
    public function getProfilePicture()
    {
        return $this->profilePicture;
    }

    /**
     * Set accessToken
     *
     * @param string $accessToken
     * @return UserOauth
     */
    public function setAccessToken($accessToken)
    {
        $this->accessToken = $accessToken;
    
        return $this;
    }

    /**
     * Get accessToken
     *
     * @return string 
     */
    public function getAccessToken()
    {
        return $this->accessToken;
    }

    /**
     * Set refreshToken
     *
     * @param string $refreshToken
     * @return UserOauth
     */
    public function setRefreshToken($refreshToken)
    {
        $this->refreshToken = $refreshToken;
    
        return $this;
    }

    /**
     * Get refreshToken
     *
     * @return string 
     */
    public function getRefreshToken()
    {
        return $this->refreshToken;
    }

    /**
     * Set tokenSecret
     *
     * @param string $tokenSecret
     * @return UserOauth
     */
    public function setTokenSecret($tokenSecret)
    {
        $this->tokenSecret = $tokenSecret;
    
        return $this;
    }

    /**
     * Get tokenSecret
     *
     * @return string 
     */
    public function getTokenSecret()
    {
        return $this->tokenSecret;
    }

    /**
     * Set expiresIn
     *
     * @param string $expiresIn
     * @return UserOauth
     */
    public function setExpiresIn($expiresIn)
    {
        $this->expiresIn = $expiresIn;
    
        return $this;
    }

    /**
     * Get expiresIn
     *
     * @return string 
     */
    public function getExpiresIn()
    {
        return $this->expiresIn;
    }

    /**
     * Get id
     *
     * @return integer 
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set user
     *
     * @param \Acme\MainBundle\Entity\User $user
     * @return UserOauth
     */
    public function setUser(\Acme\MainBundle\Entity\User $user = null)
    {
        $this->user = $user;
    
        return $this;
    }

    /**
     * Get user
     *
     * @return \Acme\MainBundle\Entity\User 
     */
    public function getUser()
    {
        return $this->user;
    }
}

Les 2 champs importants et obligatoires sont provider et providerId. Provider contiendra le nom du service (facebook ou twitter par exemple) et providerId sera l'identifiant unique de l'utilisateur sur le service externe.

 

Le controller

Le controller est la clé du succès pour faire le lien entre le firewall classique et le firewall oauth.
Ce controller devra sans doute être le même que celui qui vous permet aujourd'hui d'enregistré un utilisateur (même si ce n'est pas obligatoire, c'est plus simple)
Son fonctionnement est le suivant :

  • On reçoit une requête sur targetAction, l'utilisateur vient de se connecter via un service externe
  • On enregistre les informations de la classe OAuthUser en session Flash les infos de l'utilisateur externe
  • On le redirige vers oauthRegister (qui est derrière le firewall normal). Et là, plusieurs possibilités :
    • Le UserOauth n'existe pas et l'utilisateur n'est pas connecté : On l'envoie sur le formulaire d'inscription en enregistrant en session Flash les infos de l'utilisateur externe.
    • Le UserOauth n'existe pas et l'utilisateur est connecté : On enregistre ces infos en Base de données et on le redirige vers la home (il vient d'ajouter un nouveau service à son compte)
    • Le UserOauth existe : On le met à jour et on connecte l'utilisateur correspondant (il vient de se reconnecter)

Le point qui reste à éclairer est le formulaire d'inscription, qui est pour moi dans le même controller. C'est le seul point qui devra être repris sur un projet existant pour bien prendre en comtpe le Oauth.
Son fonctionnement est assez simple :

  • On vérifie si on a des informations en session Flash provenant de l'Oauth.
  • Si ce n'est pas le cas, on est dans une inscription classique et on ne fera rien de plus
  • Si c'est le cas, avant de créer le formulaire, on prérempli le champ email et username que l'on a peut-être provenant de l'Oauth
  • On réenregistre en session Flash les infos de l'oauth pour les avoir de nouveau au post du formulaire
  • Lorsque le formulaire est valide et que l'on peut créer l'utilisateur, on persiste aussi une ligne dans userOauth et le tour est joué

Et voici la classe :

// Acme/MainBundle/Controller/SecurityController.php
<?php 

namespace Acme\MainBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Security\Core\SecurityContext;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken,
	Symfony\Component\Security\Core\AuthenticationEvents,
	Symfony\Component\Security\Core\Event\AuthenticationEvent;

class SecurityController extends Controller {
	
	protected $oauthDataKey = 'oAuthData';
	
	public function oauthTargetAction() {
		$user = $this->getUser();
		$this->get('session')->getFlashBag()->set($this->oauthDataKey, $user->getData());
		return $this->redirect($this->generateUrl('oauth_register'));
	}
	
	public function oauthRegisterAction() {
		$oAuthData = $this->get('session')->getFlashBag()->get($this->oauthDataKey);
		
		if (!$oAuthData || !is_array($oAuthData) || !isset($oAuthData['provider']) || !isset($oAuthData['providerId']))
			return $this->redirect($this->generateUrl('hwi_oauth_connect'));
		
		// Search for userOauth
		$userOauth = $this->getDoctrine()->getRepository('AcmeMainBundle:UserOauth')->findOneBy(array(
			'provider'=>$oAuthData['provider'],
			'providerId'=>$oAuthData['providerId'],
		));
		if ($userOauth) {
			// We found it, update user oauth data
			$this->setUserOauthData($userOauth, $oAuthData);
			$this->getDoctrine()->getManager()->flush();
			
			// Log the user
			$user = $userOauth->getUser();
			$this->logUser($user);
		} else {
			$user = $this->getUser();
			if ($user && is_object($user)) {
				// User is already connected, just add the UserOauth
				$userOauth = $this->getNewUserOauth($user, $oAuthData);
				$this->getDoctrine()->getManager()->flush();
			} else {
				// Not logged and not existing, redirect to register page
				$this->get('session')->getFlashBag()->set($this->oauthDataKey, $oAuthData);
				return $this->redirect($this->generateUrl('register'));
			}
		}
		return $this->redirect($this->generateUrl('_homepage'));
	}
	
	/**
	 * Get a new UserOauth entity with persisting it
	 *
	 * @param \Acme\MainBundle\Entity\User $user
	 * @param array $oAuthData
	 * @return \Acme\MainBundle\Entity\UserOauth
	 */
	protected function getNewUserOauth($user, $oAuthData) {
		$userOauth = new \Acme\MainBundle\Entity\UserOauth();
		$userOauth->setUser($user);
		$this->setUserOauthData($userOauth, $oAuthData);
		$this->getDoctrine()->getManager()->persist($userOauth);
		return $userOauth;
	}
	
	protected function setUserOauthData($userOauth, $oAuthData) {
		foreach($oAuthData as $k=>$v) {
			$fct = 'set'.ucfirst($k);
			$userOauth->$fct($v);
		}
	}
	
	public function logUser(\Acme\MainBundle\Entity\User $user) {
		// Here, "main" is the name of the firewall in your security.yml
		$token = new UsernamePasswordToken($user, null, 'main', $user->getRoles());
		$this->get('security.context')->setToken($token);

		// Fire the login event
		$this->get('event_dispatcher')->dispatch(AuthenticationEvents::AUTHENTICATION_SUCCESS, new AuthenticationEvent($token));
	}
	
	public function registerAction() {
		$user = new \Acme\MainBundle\Entity\User();
		
		$oAuthData = $this->get('session')->getFlashBag()->get($this->oauthDataKey);
		if ($oAuthData && is_array($oAuthData)) {
			if (isset($oAuthData['email']) && $oAuthData['email'])
				$user->setEmail($oAuthData['email']);
			if (isset($oAuthData['nickname']) && $oAuthData['nickname'])
				$user->setUsername($oAuthData['nickname']);
		} else {
			$oAuthData = false;
		}
		
		$form = $this->createFormBuilder($user)
					// Create your form normally
					->getForm();
		
		$form->handleRequest($this->getRequest());
		if ($form->isValid()) {
			// Handle the user normally, preparing for persistence
			$this->getDoctrine()->getManager()->persist($user);
			
			if ($oAuthData && is_array($oAuthData))
				$userOauth = $this->getNewUserOauth($user, $oAuthData);
				
			$this->getDoctrine()->getManager()->flush();
			$this->logUser($user);
			return $this->redirect($this->generateUrl('_homepage'));
		}
		
		// Keep oAuthData in flashbag
		if ($oAuthData)
			$this->get('session')->getFlashBag()->set($this->oauthDataKey, $oAuthData);
		
        return $this->render(
            'AcmeMainBundle:Security:register.html.php',
            array(
				'form'=>$form->createView()
			)
        );
	}
	
}

Et voilà, vous avez tout, en place. Il ne reste maintenant qu'à créer des liens vers les connexions paramétrées

 

Liens vers les connect

Pour créer les liens vers les différents services externes de connexion, il faut récupérer les existants, puis créer les bonnes URLs. Dans un controller, on peut faire ça très simplement :

protected function getOauthUrls() {
	$ret = array();
	foreach($this->get('hwi_oauth.security.oauth_utils')->getResourceOwners() as $name)
		$ret[$name] = $this->generateUrl('hwi_oauth_service_redirect', array('service'=>$name));
	return $ret;
}



Hé bien ce fut bien long... Mais après une absence de près d'un an et demi, il fallait au moins ça !

 

Ca vous a plus ? Des remarques ? Des questions ? Les commentaires sotn là pour ça.

Revue de presse de la calculette freemobile

Vous avez peut-etre (sûrement ?) entendu parlé de la calculatrice pour savoir s'il est avantageux de changer d'opérateur mobile, que j'ai mis en place une heure après le l'annonce de l'offre de free mobile.

Comme j'ai été le premier à mettre en ligne cet outil, l'echo a été fulgurant et les partages sur Twitter et Facebook ont dépassé tout ce que j'aurai jamais pu imaginer.

En attendant un vrai retour sur cette expérience à tête reposée, voici la revue de presse (qui évoluera au fil du temps) de cette fabuleuse expérience.

 

Et je ne le dirai jamais assez, mais merci encore à tous ceux qui ont partagé ce lien, qui m'ont conseillé, donné des idées d'amélioration, etc...

Un grand merci à Aymeric aka Miho qui m'a proposé un graphisme rapidement.

Google Map on demand with jQuery

It's pretty common to embed a map on a contact page. But you want this map only on this page. So you don't need to load the Google Map API on every page of your site

Coupled with adress microdata (which you should already have in place), it becomes easy to load a google map on demand with a marker to the desired address.

1. Load Google Map

To load google map is simple. Just call in the URL of the Ajax API. But we must ensure proper load and execute the following script when it's completly loaded. Otherwise, you call functions that doesn't exist on the page.

A simple callback on the load of the script isn't sufficient because Google actually loads an other JS file, corresponding to the current Google Map API version.

I you readSi the doc, you understand you can add a callback that will be called by the APi once loaded

We'll write a function to load this JS file and add a callback given as a parameter. A small addition will permit to load it only one time.

var gmapUrl = 'http://maps.google.com/maps/api/js?sensor=false',
	gmapLoaded = false,
	loadGmap = function(clb) {
		if (!gmapLoaded) {
			window.gMapClb = function() {
				window.gMapClb = null;
				gmapLoaded = true;
				clb();
			};
			$.ajax({url: gmapUrl+'&callback=gMapClb', dataType: 'script'});
		} else {
			clb();
		}
	};

We can now dynamically load the Google Map API and execute a function when everything has come up.

2. Read microdata

The purpose of the code we're writing is to be called on a container, find Address microdata and display the corresponding map.

Once the address is found, we will use geocode from Googme Map to locate the map and add a marker.

Reading microdata is pretty easy thanks to jQuery. We're adding every element in an array:

var adr = me.find('[itemtype="http://data-vocabulary.org/Address"]');
if (adr.length) {
	var search = [],
		elm;
	$.each(['street-address', 'postal-code', 'locality', 'region', 'country-name'], function() {
		elm = adr.find('[itemprop="'+this+'"]');
		if (elm.length)
			search.push(elm.text());
	});
}

Once the address is on the search Array, it only remains to call the geocoder to find its location.

3. Locate address and show the map

We've got the address. We can load the Google Map API on demand with a callback. We still have to find the address location. And if we have it, show the map. To search it, I won't discuss here all the details of Geocoder.

When getting a result from geocoder, a div[class=map] is append to the containing element and the Google Map instance will be created on it. It's the CSS duty to positionnate this div and giving it a height and width.

To allow a toffle of this map, we will save a reference of the map container, a reference of the Google map and the location of the result. A marker is automaticall added at the result location.

loadGmap(function() {
	var geocoder = new google.maps.Geocoder();
	geocoder.geocode({address: search.join(', ')}, function(results, status) {
		if (results.length && status == google.maps.GeocoderStatus.OK) {
			var map = $('<div class="map" />').appendTo(me),
				GMap = new google.maps.Map(map.get(0), {
					center: results[0].geometry.location,
					mapTypeId: google.maps.MapTypeId.ROADMAP,
					zoom: defaultZoom
				});
			new google.maps.Marker({
				map: GMap,
				visible: true,
				position: results[0].geometry.location
			})
			me.data('gmap', {
				map: map,
				GMap: GMap,
				location: results[0].geometry.location
			});
		}
	});
});

Note the defaultZoom variable defined elsewhere. (required to create a map)

 

4. Put it all together

We have the 3 necessary elements to view the map. Let's put that in a jQuery plugin, add functionnality to hide/show it on each call of this plugin and we get this:

$(function() {
	var defaultZoom = 15,
		gmapUrl = 'http://maps.google.com/maps/api/js?sensor=false',
		gmapLoaded = false,
		loadGmap = function(clb) {
			if (!gmapLoaded) {
				window.gMapClb = function() {
					window.gMapClb = null;
					gmapLoaded = true;
					clb();
				};
				$.ajax({url: gmapUrl+'&callback=gMapClb', dataType: 'script'});
			} else
				clb();
		};
	
	$.fn.extend({
		myGmap: function() {
			return this.each(function() {
				var me = $(this);
				if (!me.data('gmap')) {
					var adr = me.find('[itemtype="http://data-vocabulary.org/Address"]');
					if (adr.length) {
						var search = [],
							elm;
						$.each(['street-address', 'postal-code', 'locality', 'region', 'country-name'], function() {
							elm = adr.find('[itemprop="'+this+'"]');
							if (elm.length)
								search.push(elm.text());
						});
						if (search.length) {
							loadGmap(function() {
								var geocoder = new google.maps.Geocoder();
								geocoder.geocode({address: search.join(', ')}, function(results, status) {
									if (results.length && status == google.maps.GeocoderStatus.OK) {
										var map = $('<div class="map" />').appendTo(me),
											GMap = new google.maps.Map(map.get(0), {
												center: results[0].geometry.location,
												mapTypeId: google.maps.MapTypeId.ROADMAP,
												zoom: defaultZoom
											});
										new google.maps.Marker({
											map: GMap,
											visible: true,
											position: results[0].geometry.location
										})
										me.data('gmap', {
											map: map,
											GMap: GMap,
											location: results[0].geometry.location
										});
									}
								});
							});
						}
					}
				} else {
					me.data('gmap').map.fadeToggle(function() {
						if (me.data('gmap').map.is(':not(:visible)')) {
							me.data('gmap').GMap.setZoom(defaultZoom);
							me.data('gmap').GMap.panTo(me.data('gmap').location);
						}
					});
				}
			});
		}
	});
});

When the map is hidden, the map is centered again and the default zoom is resset to the default value.

Enjoy!

Version française de ce billet.

Carte Google Map à la volée avec jQuery

Il n'est pas rare de vouloir intégrer une carte sur une page contact d'un site. Mais on ne veut afficher une carte uniquement sur cette page. C'est donc dommage de charger le javascript de Google Map sur toutes les pages.

Couplé à des micro-data d'adresse (que vous devriez déjà avoir mis en place), il devient facile de charger une carte google à la volée avec un marker à l'adresse désirée.

1. Charger Google Map

Pour charger google map, c'est simple. Il suffit d'appeler en Ajax l'URL de l'API. Mais il faut aussi s'assurer de son bon chargement et n'exécuter la suite du script que lorsqu'il est complètement chargé. Sinon, on se retrouve à appeler des fonctions qui n'existent pas encore sur la page.

Un simple callback sur le chargement du script ne suffit pas puisque Google charge encore un autre fichier JS qui correspond en réalité à la dernière version de l'API.

Si l'on lit la doc, on comprend que l'on peut ajouter un callback que l'API appelera une fois chargée.

Nous allons donc écrire une fonction pour charger le fichier JS et appeler un callback donné en paramètre lorsque celle sera en place. Un petit ajout permettra de ne charger qu'une seule fois si on exécute plusieurs la fonction :

var gmapUrl = 'http://maps.google.com/maps/api/js?sensor=false',
	gmapLoaded = false,
	loadGmap = function(clb) {
		if (!gmapLoaded) {
			window.gMapClb = function() {
				window.gMapClb = null;
				gmapLoaded = true;
				clb();
			};
			$.ajax({url: gmapUrl+'&callback=gMapClb', dataType: 'script'});
		} else {
			clb();
		}
	};

On peut donc maintenant charger dynamiquement l'API de Google Map, et exécuter un code lorsque tout est arrivé.

2. Lire le micro-format

Le but du code que nous sommes en train d'écrire permettra d'être appelé sur un bloc contenant, de chercher le micro-format d'adresse et d'afficher la carte correspondante.

Une fois les informations de l'adresse trouvées, nous l'utiliserons avec le geocoder de Google Map pour positionner la carte et ajouter le marker.

La lecture du mirco-format est hyper simple, on met chacun des éléments à la suite dans un tableau :

var adr = me.find('[itemtype="http://data-vocabulary.org/Address"]');
if (adr.length) {
	var search = [],
		elm;
	$.each(['street-address', 'postal-code', 'locality', 'region', 'country-name'], function() {
		elm = adr.find('[itemprop="'+this+'"]');
		if (elm.length)
			search.push(elm.text());
	});
}

Une fois cette adresse complétée dans le tableau search, il ne restera plus qu'à appeler le geocoder pour chercher cette adresse.

3. Chercher l'adresse et afficher la carte

Nous avons l'adresse. Nous savons charger la Google Map API à la volée avec un callback. Il ne reste plus qu'à chercher l'adresse, et si l'on trouve, affiché cette carte. Pour la recherche d'adresse, je ne détaillerai pas ici les détails du Geocoder.

Au résultat du geocoder, on ajouter une div[class=map] à l'élément contenant et l'instance de la carte Google sera créée dans cette div. Charge à la CSS du site de positionner cette div, en lui donnant une hauteur et largeur adequat.

Pour permettre un toggle de cette map, on enregistrera dans une data la référence du conteneur de la carte, la référence de la carte et la position du résultat. Le marker est automatiquement ajouté à l'emplacement du résultat.

loadGmap(function() {
	var geocoder = new google.maps.Geocoder();
	geocoder.geocode({address: search.join(', ')}, function(results, status) {
		if (results.length && status == google.maps.GeocoderStatus.OK) {
			var map = $('<div class="map" />').appendTo(me),
				GMap = new google.maps.Map(map.get(0), {
					center: results[0].geometry.location,
					mapTypeId: google.maps.MapTypeId.ROADMAP,
					zoom: defaultZoom
				});
			new google.maps.Marker({
				map: GMap,
				visible: true,
				position: results[0].geometry.location
			})
			me.data('gmap', {
				map: map,
				GMap: GMap,
				location: results[0].geometry.location
			});
		}
	});
});

On remarquera la variable defaultZoom défini ailleurs. (indispensable pour la création d'une map)

 

4. On assemble le tout

On a les 3 éléments indispensable pour afficher cette carte. Mettons tout ceci dans un plugin jQuery, ajoutons la fonctionnalité de caché/affiché la carte à chaque appel de ce plugin et nous obtenons ceci :

$(function() {
	var defaultZoom = 15,
		gmapUrl = 'http://maps.google.com/maps/api/js?sensor=false',
		gmapLoaded = false,
		loadGmap = function(clb) {
			if (!gmapLoaded) {
				window.gMapClb = function() {
					window.gMapClb = null;
					gmapLoaded = true;
					clb();
				};
				$.ajax({url: gmapUrl+'&callback=gMapClb', dataType: 'script'});
			} else
				clb();
		};
	
	$.fn.extend({
		myGmap: function() {
			return this.each(function() {
				var me = $(this);
				if (!me.data('gmap')) {
					var adr = me.find('[itemtype="http://data-vocabulary.org/Address"]');
					if (adr.length) {
						var search = [],
							elm;
						$.each(['street-address', 'postal-code', 'locality', 'region', 'country-name'], function() {
							elm = adr.find('[itemprop="'+this+'"]');
							if (elm.length)
								search.push(elm.text());
						});
						if (search.length) {
							loadGmap(function() {
								var geocoder = new google.maps.Geocoder();
								geocoder.geocode({address: search.join(', ')}, function(results, status) {
									if (results.length && status == google.maps.GeocoderStatus.OK) {
										var map = $('<div class="map" />').appendTo(me),
											GMap = new google.maps.Map(map.get(0), {
												center: results[0].geometry.location,
												mapTypeId: google.maps.MapTypeId.ROADMAP,
												zoom: defaultZoom
											});
										new google.maps.Marker({
											map: GMap,
											visible: true,
											position: results[0].geometry.location
										})
										me.data('gmap', {
											map: map,
											GMap: GMap,
											location: results[0].geometry.location
										});
									}
								});
							});
						}
					}
				} else {
					me.data('gmap').map.fadeToggle(function() {
						if (me.data('gmap').map.is(':not(:visible)')) {
							me.data('gmap').GMap.setZoom(defaultZoom);
							me.data('gmap').GMap.panTo(me.data('gmap').location);
						}
					});
				}
			});
		}
	});
});

Lorsqu'on cache la carte, on en profite pour recentrer la map et remettre le niveau de zoom à celui paramétré par défaut.

Enjoy!

English version of this post.

jQuery UI Position : Change the collision function

For a tooltip script quickly developed for a big current project I'm working on, I'm using the position plugin of jQuery UI.

This function allow you to positionnate an element exactly how you need relative to another element. The ultimate function when dealing with tooltip.

This plugin comes with a detection collision with the windows. In other words, if the positionnate element goes outside the window, then it's positionnate on the other side.

The only problem is that there is no possibility to detect when this collision is happening.

By reading the code I found a simple solution to detect it and a way to add a class to the positionnating element. In my case it is useful in order to change the arrow of the tooltip on the other side.

 

Here is the code:

var currentlyPositionning,
    initPos;
$.ui.position.custom = {
    left: function(position, data) {
        initPos = position.left;
        $.ui.position.flip.left(position, data);
        if (initPos != position.left) {
            currentlyPositionning.addClass('tooltipFlip');
        }
    },
    top: function(position, data) {
        initPos = position.top;
        $.ui.position.flip.top(position, data);
        if (initPos != position.top) {
            currentlyPositionning.addClass('tooltipFlip');
        }
    }
};

Actually we're simply adding a custom option to the plugin. For each top ou left properties, the code is checking if the value is changed by the original collision detection function. If it's the case, then we're adding a class to the element.

You probably notice the currentlyPositionning variable; It should correspond to the element that is currently positionned. Indeed there is no information on the data object to let us find it back.

 

Finally, to use this new functionnality you should call the position function as following::

currentlyPositionning = elem;
elem.position({
    of: me,
    my: 'left bottom',
    at: 'right top',
    offset: '15px',
    collision: 'custom'
});

Et voilà.

Version française de ce billet.