Pull to refresh

Смогут ли подружиться Angular.js и Facebook Login?

JavaScript *Node.JS *Angular *
Sandbox
Tutorial
Приветствую, дорогие читатели Хабра!

Свой первый пост, мне хотелось бы посвятить тому, с чем, пока что, интересней всего работать — Angular и Node.

За некоторое время, (около 7 месяцев) работы с Angular, появилось пару своих наработок, которыми горю желанием поделиться. Конечно же это не сам Facebook Login, каким его описывают в разделе Facebook JS SDK, и не «Hello World with Angular.js», но все довольно-таки просто.

Мотивация, в написании этой статьи, — желание делиться кое-каким опытом в интересных направлениях.

[небольшой дисклеймер]

Возможно, все что происходит ниже данного текста — взрывоопасная смесь бреда с кусками кода, но я искренне надеюсь на объективную оценку и помощь опытных разработчиков в выборе более правильных\интересных решений.

Backend


Выбор мой пал на Node не случайно. Ну люблю я JavaScript. К тому же очень удобно, как мне кажется. Главное есть возможность быстро и бесплатно развернуть веб-приложение в интернете. Перечислять «почему» не имеет смысла. В Качестве фреймворка на стороне сервера выбрал Express, т.к. по нему есть уйма статей howto, очень легко разобраться, простой роутинг. Это все что пока что нужно.

Дальше мы более подробней попытаемся разобрать важные моменты.

Взглянем, как сервер реагирует на запросы из браузера:

app.js
var express     = require('express')
,   expressLayouts = require('express-ejs-layouts')
,   less        = require('less-middleware')
,   routes      = require('./routes')
,   config      = require('./settings')
,   http        = require('http')
,   path        = require('path')
,   app         = express()
;

// all environments
app.set('view engine', 'ejs');
app.set('views', __dirname + '/views');
app.set('layout', 'layout');
app.locals({
    appName: config.APP_NAME,
    appId: config.APP_ID,
    appUrl: config.APP_URL,
    scope: config.SCOPE,
    random: Math.random()
});
app.use(expressLayouts);
app.use(express.favicon());
app.use(express.logger('dev'));
app.use(express.bodyParser());
app.use(express.methodOverride());
app.use(express.cookieParser());
app.use(express.session({ secret: config.SECRET, maxAge: new Date(Date.now() + 3600000) }));
app.use(require('less-middleware')({
    force: true,
    dest: path.join(__dirname, 'public', 'css'),
    src:  path.join(__dirname, 'less'),
    prefix: '/static/css/'
}));
app.use('/static', express.static(path.join(__dirname, 'public')));
app.use('/static', express.static(path.join(__dirname, 'bower_components')));
app.use(app.router);

// landing page
app.get('/'                             , routes.index);
app.get('/partials/:name'               , routes.partials);
app.get('/partials/:folder/:name'       , routes.partials);
app.all(/^(\/((?!static)\w.+))+$/       , routes.index);


http.createServer(app).listen(config.PORT, function(){
  console.log('Express server listening on port ' + config.PORT);
});




Остановимся только на роутинге, т.к. остальное — это чисто технические настройки, большинство из которых часто описываются в туториалах типа «Get started with Express»

Поскольку мы готовимся создать single-page приложение, нам нужно настроить сервер так, что бы на запрос он отдавал всегда одну и туже страницу с тегом <ng-view></ng-view>, для того, что бы Angular смог сам обработать текущий URL в соответствии с роутингом на стороне клиента и подгрузить соответствующий partial (об этом позже..)

Так вот — Как нам объяснить Express'у, что есть разница между запросами статических файлов (js, css, images), и обычных страниц (если они у нас не описываются на сервере) так, что бы:
— он не ругался нам о несуществующих путях страниц и не мешал работать Angular'у;
— он всё же высказал вам всё, что о вас думает, если вы запросили несуществующий статический файл;

Вот как-то так:
// для начала опишем папки со статикой 
app.use('/static', express.static(path.join(__dirname, 'public')));
app.use('/static', express.static(path.join(__dirname, 'bower_components')));
app.use(app.router); // Важно - только после определения статических файлов мы подключаем роутер.

// landing page
app.get('/'                             , routes.index); // GET запрос к главной будет отдавать index, т.е. homepage с тегом <ng-view>
app.get('/partials/:name'               , routes.partials); // указываем, что нам нужен доступ к Partials'ам для Angular'a
app.get('/partials/:folder/:name'       , routes.partials); // тоже самое, только шаблон вложен в папку
app.all(/^(\/((?!static)\w.+))+$/       , routes.index); // Пытаемся на пальцах объяснить, что "все, что не несет в себе приставку /static/ будет расцениваться как страничка для Angular'a". 


Появляется логичный вопрос — А как нам сообщить пользователю об ошибке 404, если запрошеной странички ( к примеру /ololo ) у нас на самом деле не предусмотрено?

Frontend


Прежде чем я расскажу как Angular может понять, где вы ошиблись, при переходе на страницу, давайте рассмотрим как выглядит файловая структура приложения (да и всей статики на сервере, не включая сторонних библиотек из bower)

Скрытый текст
├── public
│   ├── css
│   │   └── style.css
│   ├── images
│   │   └── fb_button.png
│   ├── js
│   │   ├── application.js — инициализация приложения. Подробнее позже…
│   │   ├── controllers.js — глобальный модуль для контроллеров
│   │   ├── directives.js — глобальный модуль для директив
│   │   ├── filters.js — для фильтров
│   │   ├── services.js — для сервисов
│   │   └── modules — в этой папки хранятся сущности приложения
│   │      └── friends.js — вот одна из них — страница /friends и контроллеры, директивы, и фильтры, касающиеся только этой сущности описываются здесь

В общем-то папку modules можно было назвать и по-другому — pages например…
т.е. говоря в двух словах — есть 2 области модулей:
— глобальная — описываются директивы, фильтры, сервисы, которые могут быть использованы по всему проекту
— сущностная — описывается роутинг для сущности, и так же — директивы, фильтры, сервичы, которые однозначно относятся только к этой сущности и не могут быть использованы за рамками этого модуля.

Ну это все, конечно, в теории… Каждый делает, как ему душа пожелает, но данный подход помог мне не утонуть в лапше из бесконечных мелких директив и внезапных контроллеров посреди шаблонов. Для примеров, наверное, понадобится отдельная статья. Вернемся все же к нашей теме.

layout.ejs
<html ng-app="angularfb">
<head>
    <title>Facebook app</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="/static/css/style.css">
    <link rel="stylesheet" href="/static/bootstrap/dist/css/bootstrap.css">
    <link rel="stylesheet" href="/static/bootstrap/dist/css/bootstrap-theme.css">
    <link rel="stylesheet" href="/static/font-awesome/css/font-awesome.min.css">
    <!--[if IE 7]>
    <link rel="stylesheet" href="/static/font-awesome/css/font-awesome-ie7.min.css">
    <![endif]-->
    <link href='http://fonts.googleapis.com/css?family=Domine' rel='stylesheet' type='text/css'>
    <style type="text/css">
        [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak {
            display: none !important;
        }
    </style>
    <script type="text/javascript">
        // вытянули из конфига все что у нас есть и может пригодиться, касательно Facebook API
        FB_APP_NAME = '<%= appName %>';
        FB_APP_ID   = '<%= appId %>' ;
        FB_APP_URL  = '<%= appUrl %>';
        FB_SCOPE    = '<%= scope %>';
    </script>

</head>

<body ng-class="{'page-loader': !$root.user}">
    <!-- Если мы получили объект FB user'a, то можем отобразить все приложение  -->
    <!--  body покажет всегда <ng-view> и Angular подкинет туда соответствующий текущей URL шаблон. -->
    <div ng-if="$root.user">
        <%- include navigation %>
        <%- body %>
        <%- include footer %>
    </div>
    <!-- Показываем иконку загрузки если нет объекта FB user'a  -->
    <div ng-if="!$root.user" ng-cloak>
        <h2 class="text-center" ng-if="!auth.status">
            <i class="icon-spin icon-spinner"></i> Loading...
        </h2>
        <!-- Покажем кнопку login with Facebook если мы получили ответ из auth.authResponseChange  -->
        <a ng-if="auth.status && auth.status != 'connected'" class="fb-btn" ng-click="login()"></a>
    </div>
</body>

<script src="//connect.facebook.net/en_US/all.js"></script>
<script src="/static/jquery/jquery.min.js"></script>
<script src="/static/bootstrap/dist/js/bootstrap.min.js"></script>
<script src="/static/underscore/underscore-min.js"></script>
<script src="/static/angular/angular.js"></script>
<script src="/static/angular-resource/angular-resource.min.js"></script>
<script src="/static/angular-route/angular-route.min.js"></script>

<script src="/static/js/services.js?<%= random %>"></script>
<script src="/static/js/filters.js?<%= random %>"></script>
<script src="/static/js/controllers.js?<%= random %>"></script>
<script src="/static/js/directives.js?<%= random %>"></script>
<script src="/static/js/modules/friends.js?<%= random %>"></script>
<script src="/static/js/application.js?<%= random %>"></script>
</html>



application.js
(function() {
    'use strict';

    angular.module('angularfb', [
            'ngRoute',
            'ngResource',
            'angularfb.filters',
            'angularfb.controllers',
            'angularfb.services',
            'angularfb.directives',
            'angularfb.friends'
        ])

    .config([ '$routeProvider', '$locationProvider',
        function($routeProvider, $locationProvider){
            $locationProvider.html5Mode(true);
            $routeProvider
                .when('/', {
                    templateUrl: '/partials/homepage',
                    controller: 'HomePageCtrl'
                })

                .when('/logout', {
                    resolve: [ '$rootScope', 'API', '$location', function($rootScope, API, $location){
                        API.logout(function(){
                            console.log('Logout... redirecting...');
                            $location.path('/')
                            $rootScope.$apply();
                        });
                    }]
                })

                .otherwise();
        }
    ])

    .run(['$rootScope', '$location', 'API', function($rootScope, $location, API){
        FB.init({
            appId      : FB_APP_ID,
            channelUrl : FB_APP_URL,
            status     : true,
            xfbml      : true,
            oauth      : true
        });

        console.log('RUN!');
        
        // метод для кнопки Login With Facebook
        $rootScope.login = function(){
            API.login(function(){
                console.log("Logged in. Redirecting...")
                $location.path('/');
            }, function(){
                console.log("not logged in... error...")
            });
        }
        // пытаемся получить текущее состояние
        API.getLoginStatus(
            function( response ){
                console.info("Authorized:", response)
            },
            function( response ){
                console.error("Not authorized:", response)
            }
        )

        FB.Event.subscribe('auth.authResponseChange', function(response) {
            console.log('got AuthResponseChange:', response);
            if (response.status === 'connected') {
                API.me().then(
                    function(resp){
                        console.log('got user Info', resp);
                    },
                    function(error){
                        console.log("got user Info error", error);
                    }
                );
            }
            else {
                $rootScope.user = null; // и скроется вся наша страничка из-за ng-if директивы (см. layout.ejs) 
                $location.path('/');
            }
            $rootScope.auth = response;
            $rootScope.$$phase || $rootScope.$apply();
        });


        $rootScope.$on('$routeChangeStart', function(event, next, current){
            if ( !next.$$route )
                next.templateUrl = '/partials/error';
        })

    }])

    .controller('HomePageCtrl', [  '$scope', function($scope){ /* .... */ }])

}());


services.js
(function(){
    'use strict';
    angular.module('angularfb.services', [])
        .factory("API", [ '$rootScope', '$q', '$location', '$exceptionHandler',
            function($rootScope, $q, $location, $exceptionHandler){
                return  {
                    me: function(){
                        var def = $q.defer();
                        FB.api('/me', function(response){
                            def.resolve($rootScope.user = response); // как появился пользователь у нас в $root, так и отобразим в layout.ejs "все что скрыто"
                        })
                        return def.promise;
                    },

                    getLoginStatus: function(successCallback, errorCallback){
                        var self = this;
                        FB.getLoginStatus(function(response) {
                            self._processAuthResponse( response, successCallback, errorCallback )
                        });
                    },

                    login: function(successCallback, errorCallback){
                        var self = this;
                        FB.login(function(response){
                            self._processAuthResponse( response, successCallback, errorCallback );
                        }, { scope: FB_SCOPE } )
                    },

                    logout: function( logoutCallback ){
                        return FB.logout(function( response ){
                            if (_.isFunction( logoutCallback ))
                                logoutCallback.call(this, arguments)
                            else {
                                $location.path('/')
                                $rootScope.user = null;
                            }
                            $rootScope.auth = response;

                        })
                    },

                    _processAuthResponse: function( response, successCb, errorCb ) {
                        var self = this;
                        if (response.authResponse) {
                            if (_.isFunction(successCb))
                                successCb.call(this, response)
                            else
                            if(_.isUndefined(successCb)){
                                $location.path('/')
                            }
                            else
                                throw new Error("Success callback should be a function")
                        } else {
                            if (_.isFunction(errorCb))
                                errorCb.call(this, response)
                            else
                            if(_.isUndefined(errorCb)){}
                            else
                                throw new Error("Error callback should be a function")
                        }
                        $rootScope.auth = response;
                        self._applyScope();
                    },

                    _applyScope: function( cb ) {
                        if (!$rootScope.$$phase) {
                            try {
                                $rootScope.$eval( cb );
                            } catch (e) {
                                $exceptionHandler(e);
                            } finally {
                                try {
                                    $rootScope.$apply();
                                } catch (e) {
                                    $exceptionHandler(e);
                                    throw e;
                                }
                            }
                        } else {
                            $rootScope.$eval( cb );
                        }
                    }
                }
            }
        ])
})();



Предполагаю, что будут ругать за такие велосипеды в сервисе API, как с successCallback, errorCallback и _processAuthResponse хотя бы потому, что можно было обойтись намного проще. Захотелось мне повелосипедить, нагородил немного… За то, все как-то, вроде бы неплохо складывается в работе самой авторизации.

Facebook Login


А как же Facebook? Упоминания в заголовке, несколько раз в тексте, а объяснений никаких… Прошу прощения, виноват.

Так как же мы всегда держим вкурсе наше приложение о состоянии авторизации?
Разберем код по частям:

// в главном модуле, application.js, в методе .run(),
// мы запрашиваем Login Status
API.getLoginStatus()

// и подписываемся на event "auth.authResponseChange", который 
// будет сообщать нам об изменении состояния авторизации после запроса.
// response вмещает в себе {authResponse: Object, status: String } 
// поэтому, если пользователь авторизован, то response.status === 'connected'
// Попросим API сервис записать в $rootScope.user объект '/me' пользователя,
// а вконце хендлера записываем весь response в $rootScope.auth
// В пределах приложения мы будем иметь доступ к состоянию авторизации
// и пользователю, если он получен. 
FB.Event.subscribe('auth.authResponseChange', function(response) {
    if (response.status === 'connected'){
        API.me()
    } else {
        $rootScope.user = null;
        $location.path('/');
    }
    $rootScope.auth = response;
    $rootScope.$$phase || $rootScope.$apply();
});


Пока у нас нет $root.user, приложение скрывает основной контент и вместо этого показывает 'Loading..'. А если $root.auth уже пришел, и статус !== 'connected', то покажем кнопку «Connect with Facebook». Если пользователь нажмет кнопку и залогинится, мы получим юзера, то шаблон в layout.ejs мгновенно отреагирует и покажет основной контент с хедером, футером и <ng-view>, соответственно скроет login button и loading.

Ах да.., насчет обработки Not Found:

Может кто заметил, что в application.js, при описании $routeProvider, в методе .otherwise() нет никаких параметров для редиректа. Все неспроста. Нам ненужен редирект куда-либо (на /404 например), поскольку ошибка могла заключатся в опечатке или чего-то подобного, которую пользователь может быстренько исправить и страница отобразится как нужно, поэтому мы никуда не перекидываем, а перед тем как мы перешли по ссылке, или загрузили страницу проверяем, описан ли для такого пути какой-нибудь роут (.where())? Если нет, тогда загружаем его, и подставляем сами ему темплейт.

$rootScope.$on('$routeChangeStart', function(event, next, current){
            if ( !next.$$route )
                next.templateUrl = '/partials/error';
}) 


Таким образом у нас остается неправильная ссылка в адресной строке; информация о том, что данного пути не существует; пользователь увидел, а URL уже легко пофиксить и можно продолжать работать с приложением.

В общем


— Мы рассмотрели как Express дает легко вздохнуть Angular'у, отдавая всё на личное рассуждение, кроме статических файлов.
— Коснулись темы упорядочивания файлов Anguar.js приложения
— Убедились (надеюсь), что Angular'y можно доверить не только 404 error handling, а даже вести общение с пользователем после авторизации, опять же на клиенте, через Facebook.

А пощупать всё вживую можно здесь:
[http://angular-fb.herokuapp.com/]

Если кому-то было интересно, могу оставить ссылку на github с этим проектом.
Благодарю всех, кто читал.

UPD: дописал про Facebook Login. Спасибо Tulov_Alex
Tags:
Hubs:
Total votes 20: ↑14 and ↓6 +8
Views 8K
Comments Comments 7