Новый быстрый старт с PHPixie: строим цитатник коммит за коммитом

в 10:26, , рубрики: framework, php, phpixie, tutorial, Разработка веб-сайтов

image

За прошлый год в PHPixie добавилось много новых возможностей и несколько компонентов, к тому же немного изменилась стандартная структура бандла чтобы снизить порог вхождения для разработчиков. Так что пришло время создать новый туториал, и в этот раз мы попробуем сделать его чуть по другому. Вместо того чтобы просто смотреть на готовый демо проект с описанием, мы будем идти постепенно, при чем на каждой итерации у нас будет полностью рабочий сайт. Мы будем строить простенький цитатник с логином, регистрацией, интеграцией с соцсетями и консольными командами для статистики. Полная история коммитов на гитхабе.

1. Создание проекта

Перед тем как приступить к работе скажите "Привет" в нашем чате, 99% проблем с которыми вы можете столкнутся там решаются почти мгновенно.

Нам понадобится Composer, после его установки запускаем:

php composer.phar create-project phpixie/project

Это создаст папку project с скелетом проекта и одним бандлом 'app'. Бандлы это Бандлы это модули код, шаблоны, CSS итд. относящиеся к какой-то части приложения. Их можно легко переносить с проекта на проект используя Composer. Мы будем работать только с одним бандлом в котором и будет вся логика нашего приложения.

Дальше надо создать виртуальный хост и направит его на папку /web внутри проекта. Если все прошло гладко то зайдя на http://localhost/ в браузере вы увидите приветствие. Сразу проверим работает ли роутинг перейдя на http://localhost/greet.

Если вы на Windows то скорее всего увидите ошибку во время запуска команды create-project, это следствия того что на этой ОС PHP функция symlink() не работает. Можете просто это проигнорировать, чуть потом я покажу как обойти эту проблему.

Состояние проекта на этом этапе (Коммит 1)

2. Просмотр сообщений

Начнем с соединения с БД, для этого редактируем /assets/config/database.php. Проверить соединение можно запуском двух консольных команд с папки проекта:

./console framework:database drop   # удаляет базу если она присутствует
./console framework:database create # создает базу если она отсутсвует

Дальше создаем миграцию со структурой таблиц в /assets/migrate/migrations/1_users_and_messages.sql:

CREATE TABLE users(
  id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255) UNIQUE,
  passwordHash VARCHAR(255)
);

-- statement

CREATE TABLE messages(
  id INT PRIMARY KEY AUTO_INCREMENT,
  userId INT NOT NULL,
  text VARCHAR(255) NOT NULL,
  date DATETIME NOT NULL,

  FOREIGN KEY (userId)
      REFERENCES users(id)
);

Заметьте что мы используем -- statement для разделения запросов.

Также сразу добавим немного данных чтобы было чем наполнить базу, для этого создаем файлы в /assets/migrate/seeds/ где имя файла отвечает имени таблицы, например:

<?php
// /assets/migrate/seeds/messages.php

return [
    [
        'id'     => 1,
        'userId' => 1,
        'text'   => "Hello World!",
        'date'   => '2016-12-01 10:15:00'
    ],
    // ....
]

Полный контент этих файлов можно посмотреть на гитхабе. Теперь запустим еще две консольные команды:

./console framework:migrate # применить миграции
./console framework:seed     # наполнить базу данными

Теперь можно приступить к нашей первой странице. Сперва рассмотрим файл /bundles/app/assets/config/routeResolver.php в котором настраиваются роуты, то есть прописывается каким ссылкам отвечают какие процессоры. Мы собираемся добавить процессор messages который будет отвечать за отображение сообщений. Пропишем его как дефолтный а также сразу добавим роут для главной страницы:

return array(
    'type'      => 'group',
    'defaults'  => array('action' => 'default'),
    'resolvers' => array(

        'action' => array(
            'path' => '<processor>/<action>'
        ),

        'processor' => array(
            'path'     => '(<processor>)',
            'defaults' => array('processor' => 'messages')
        ),

        // Роут для главной страницы
        'frontpage' => array(
            'path' => '',
            'defaults' => ['processor' => 'messages']
        )
    )
);

Начнем верстку с того что изменим родительский шаблон /bundles/app/assets/template/layout.php и добавим к нему Bootstrap 4 и свой CSS.

<!DOCTYPE html>
<html lang="en">
<head>
    <!-- Bootstrap 4 -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta http-equiv="x-ua-compatible" content="ie=edge">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css">

    <!-- Подключаем наш CSS, об этом чуть позже -->
    <link rel="stylesheet" href="/bundles/app/main.css">

    <!-- Если подшаблон не установил имя страницы то используем Quickstart -->        
    <title><?=$_($this->get('pageTitle', 'Quickstart'))?></title>
</head>
<body>

<!-- Navigation -->
<nav class="navbar navbar-toggleable-md navbar-light bg-faded">
    <div class="container">

             <!-- Ссылка на главную страницу -->   
        <a class="navbar-brand  mr-auto" href="<?=$this->httpPath('app.frontpage')?>">Quickstart</a>
    </div>
</nav>

<!-- Тут будет вставлено тело дочернего шаблона -->   
<?php $this->childContent(); ?>

<!-- Bootstrap dependencies -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tether/1.3.7/js/tether.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/js/bootstrap.min.js"></script>

</body>
</html>

Где же создать файл main.css? Поскольку все нужные файлы лучше всего держать внутри бандла то это будет папка /bundles/app/web/. При создании проекта композером на эту папку автоматически создается симлинк с /bundles/app/web что делает эти файлы доступными с браузера. На Windows вместо создания ярлыка приходится копировать папку, что делает команда:

# копирует файлы с web директории бандла в /web/bundles
./console framework:installWebAssets --copy 

Теперь создаем новый процессор в /bundles/app/src/HTTP/Messages.php

namespace ProjectAppHTTP;

use PHPixieHTTPRequest;

/**
 * Просмотр сообщений
 */
class Messages extends Processor
{
    /**
     * @param Request $request HTTP request
     * @return mixed
     */
    public function defaultAction($request)
    {
        $components = $this->components();

        // Получаем все сообщения
        $messages = $components->orm()->query('message')
            ->orderDescendingBy('date')
            ->find();

        // Рендерим темплейт
        return $components->template()->get('app:messages', [
            'messages' => $messages
        ]);
    }
}

Важно: не забываем прописать его в /bundles/app/src/HTTP.php:

namespace ProjectApp;

class HTTP extends PHPixieDefaultBundleHTTP
{
    // это маппинг имени процессора к его классу
    protected $classMap = array(
        'messages' => 'ProjectAppHTTPMessages'
    );
}

Почти готово, осталось только наверстать сам шаблон app:messages который использует процессор, это самая простая часть:

<?php
// Родительский шаблон
$this->layout('app:layout');

// Устанавливаем переменную какую
// родительский шаблон затем вставит как титул страницы
$this->set('pageTitle', "Messages");
?>

<div class="container content">
    <-- Выводим сообщения -->
    <?php foreach($messages as $message): ?>

        <blockquote class="blockquote">
            <-- Выводить текст надо используя $_() для защиты от XSS -->
            <p class="mb-0"><?=$_($message->text)?></p>
            <footer class="blockquote-footer">
                posted at <?=$this->formatDate($message->date, 'j M Y, H:i')?>
            </footer>
        </blockquote>

    <?php endforeach; ?>
</div>

Все, готово, теперь перейдя на http://localhost/ мы увидим полный список сообщений.

Состояние проекта на этом этапе (Коммит 2)

3. ORM связи и разбиение на страницы

Для того чтобы под каждым сообщением указать пользователя который его создал надо прописать связь между таблицами. В миграциях мы указали что каждое сообщение включает обязательное поле userId так что это будет связь Один-ко-Многим.

// bundles/app/assets/config/orm.php

return [
    'relationships' => [
        // У каждого пользователя несколько сообщений
        [
            'type'  => 'oneToMany',
            'owner' => 'user',
            'items' => 'message'
        ]
    ]
];

Добавим новый роут с параметром page для разбиения сообщений по страницам:

// /bundles/app/assets/config/routeResolver.php

return array(
    // ....
    'resolvers' => array(
        'messages' => array(
            'path' => 'page(/<page>)',
            'defaults' => ['processor' => 'messages']
        ),
       // ....
    )
);

И чуть чуть меняем сам процессор Messages:

public function defaultAction($request)
{
    $components = $this->components();

    // Создаем запрос
    $messageQuery = $components->orm()->query('message')
        ->orderDescendingBy('date');

    // Передаем запрос в пейджер и сразу указываем количество
    // сообщений на страницу и список связей которые надо подгрузить
    $pager = $components->paginateOrm()
        ->queryPager($messageQuery, 10, ['user']);

    // Выставляем номер текущей страницы исходя из параметра
    $page = $request->attributes()->get('page', 1);
    $pager->setCurrentPage($page);

    // И рендерим темплейт
    return $components->template()->get('app:messages', [
        'pager' => $pager
    ]);
}

Теперь в шаблоне мы можем использовать $pager->getCurrentItems() чтобы получить сообщения на данной странице, и $message->user() чтобы получить данные об авторе и наверстать пейджер. Не буду копировать сюда полный шаблон страницы, его можно посмотреть в репозитории.

Состояние проекта на этом этапе (Коммит 3)

4. Авторизация пользователей

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

// /bundles/app/src/ORM/User.php

namespace ProjectAppORM;

use ProjectAppORMModelEntity;
/** Этот интерфейс необходим для логина по паролю */
use PHPixieAuthLoginRepositoryUser as LoginUser;

/**
 * Сущность пользователя
 */
class User extends Entity implements LoginUser
{
    /**
     * Возвращает хеш пароля этого пользователя.
     * В нашем случае это просто значение поля 'passwordHash'.
     * @return string|null
     */
    public function passwordHash()
    {
        return $this->getField('passwordHash');
    }
}

namespace ProjectAppORMUser;

use ProjectAppORMModelRepository;
use ProjectAppORMUser;
/** Этот интерфейс необходим для логина по паролю */
use PHPixieAuthLoginRepository as LoginUserRepository;

/**
 * Репозиторий пользователей
 */
class UserRepository extends Repository implements LoginUserRepository
{
    /**
     * Ищет пользователя по его id
     * @param mixed $id
     * @return User|null
     */
    public function getById($id)
    {
        return $this->query()
            ->in($id)
            ->findOne();
    }
    /**
     * Ищет пользователя по логину, в нашем случае это его email.
     * Но можно искать и по нескольким полям в результате позволяя логинится
     * и по мейлу и по имени юзера.
     * @param mixed $login
     * @return User|null
     */
    public function getByLogin($login)
    {
        return $this->query()
            ->where('email', $login)
            ->findOne();
    }
}

Важно: не забываем зарегистрировать эти классы в /bundles/app/src/ORM.php

namespace ProjectApp;

/**
 * Тут мы прописываем классы врапперов
 */
class ORM extends PHPixieDefaultBundleORM
{
    protected $entityMap = array(
        'user' => 'ProjectAppORMUser'
    );

    protected $repositoryMap = [
        'user' => 'ProjectAppORMUserUserRepository'
    ];
}

Пропишем настройки авторищации в /assets/config/auth.php:

// /assets/config/auth.php
return [
    'domains' => [
        'default' => [

            // использовать ORM репозиторий для пользователей
            'repository' => 'framework.orm.user',

            // Тут мы настраиваем какими способами юзер может авторизироватся 
            'providers'  => [

                // Включаем поддержку сессий
                'session' => [
                    'type' => 'http.session'
                ],

                // И паролей
                'password' => [
                    'type' => 'login.password',

                    // когда пользователь логинится паролем, запомнить его в сессии
                    'persistProviders' => ['session']
                ]
            ]
        ]
    ]
];

Осталось только добавить страницу логина, для этого создаем новый процессор:

namespace ProjectAppHTTP;

use PHPixieAuthLoginProvidersPassword;
use PHPixieHTTPRequest;
use PHPixieValidateForm;
use ProjectAppORMUserUserRepository;
use PHPixieAppORMUser;

/**
 * Тут будем обрабатывать логин и регистрацию
 */
class Auth extends Processor
{
    /**
     * @param Request $request HTTP request
     * @return mixed
     */
    public function defaultAction($request)
    {
        // Если пользователь уже залогинен, редиректим его на главную
        if($this->user()) {
            return $this->redirect('app.frontpage');
        }

        $components = $this->components();

        // Строим шаблон и форму
        $template = $components->template()->get('app:login', [
            'user' => $this->user()
        ]);

        $loginForm = $this->loginForm();
        $template->loginForm = $loginForm;

        // Если форма не засабмичена то просто рендерим темплейт
        if($request->method() !== 'POST') {
            return $template;
        }

        $data = $request->data();

        // В другом случае обрабатываем логин
        $loginForm->submit($data->get());

        // Если форма логина валидна и пользователь успешно залогинился делаем редирект
        if($loginForm->isValid() && $this->processLogin($loginForm)) {
            return $this->redirect('app.frontpage');
        }

        // Если нет то просто рендерим страницу
        return $template;
    }

    /**
     * Обработка логина
     *
     * @param Form $loginForm
     * @return bool Залогинился ли пользователь
     */
    protected function processLogin($loginForm)
    {
        // Пробуем залогинится 
        $user = $this->passwordProvider()->login(
            $loginForm->email,
            $loginForm->password
        );

        // Если пароль не подошел или такого пользователя нет, то добавляем ошибку к форме
        if($user === null) {
            $loginForm->result()->addMessageError("Invalid email or password");
            return false;
        }

        return true;
    }

    /**
     * Логаут
     * @return mixed
     */
    public function logoutAction()
    {
        // Получаем домен авторизации и забываем пользователя
        $domain = $this->components()->auth()->domain();
        $domain->forgetUser();

        // Делаем редирект на главную
        return $this->redirect('app.frontpage');
    }

    /**
     * Строим форму логина
     * @return Form
     */
    protected function loginForm()
    {
        $validate = $this->components()->validate();
        $validator = $validate->validator();

        // Используем валидатор документов 
        //(это тот который вы будете использовать в большинстве случаев)
        $document = $validator->rule()->addDocument();

        // Оба поля обязательны
        $document->valueField('email')
            ->required("Email is required");

        $document->valueField('password')
            ->required("Password is required");

        // Возвращаем форму для этого валидатора
        return $validate->form($validator);
    }

    /**
     * провайдер аутентификации какой мы настроили в /assets/config/auth.php
     * @return Password
     */
    protected function passwordProvider()
    {
        $domain = $this->components()->auth()->domain();
        return $domain->provider('password');
    }
}

Осталось только наверстать саму форму авторизации, чтобы не копировать сюда весь код, приведу пример одного поля:

<-- Добавить класс has-danger если поле не валидно -->
<div class="form-group <?=$this->if($loginForm->fieldError('email'), "has-danger")?>">

    <-- Само поле ввода  с сохранением предыдущего значения -->
    <input name="email" type="text" value="<?=$_($loginForm->fieldValue('email'))?>"
            class="form-control" placeholder="Username">

    <-- Вывод ошибки если она есть -->
    <?php if($error = $loginForm->fieldError('email')): ?>
        <div class="form-control-feedback"><?=$error?></div>
    <?php endif;?>

</div>

Так же добавляем роуты и ссылки на логин/логаут в хедер и готово, логин работает.

Состояние проекта на этом этапе (Коммит 4)

5. Регистрация

Форма регистрации делается по полной аналогии, рассмотрим изменения к процессору Auth:

/**
 * форма регистрации
 * @return Form
 */
protected function registerForm()
{
    $validate = $this->components()->validate();
    $validator = $validate->validator();
    $document = $validator->rule()->addDocument();

    // По умолчанию валидатор не пропускает поля которые не были описаны.
    // Этот вызов отключает эту проверку и пропускает дополнительные поля.
    // В нашем случае это hidden поле "register" по какому мы будем определять
    // логин это или регистрация
    $document->allowExtraFields();

    // Имя обязательное 
    $document->valueField('name')
        ->required("Name is required")
        ->addFilter()
        ->minLength(3)
        ->message("Username must contain at least 3 characters");

    // Email тоже обязательный и должен быть валидным
    $document->valueField('email')
        ->required("Email is required")
        ->filter('email', "Please provide a valid email");

   // Обязательный и минимум 8 знаков
   $document->valueField('password')
        ->required("Password is required")
            ->addFilter()
            ->minLength(8)
            ->message("Password must contain at least 8 characters");

  // Тоже обязательное поле
   $document->valueField('passwordConfirm')
        ->required("Please repeat your password");

  // В этом коллбеке проверяем что пароль и его подтверждение совпадают
  $validator->rule()->callback(function($result, $value) {
        // Если они разные добавляем ошибку
        if($value['password'] !== $value['passwordConfirm']) {
            $result->field('passwordConfirm')->addMessageError("Passwords don't match");
        }
  });

  // Строим форму для этого валидатора
  return $validate->form($validator);
}

/**
 * Обработка регистрации
 * @param Form $registerForm
 * @return bool Прошла ли регистрация успешно
 */
protected function processRegister($registerForm)
{
    /** @var UserRepository $userRepository */
    $userRepository = $this->components()->orm()->repository('user');

    // Если email уже занят то добавляем ошибку к форме
    if($userRepository->getByLogin($registerForm->email)) {
        $registerForm->result()->field('email')->addMessageError("This email is already taken");
        return false;
    }

    // Хешируем пароль и создаем пользователя

    $provider = $this->passwordProvider();
    $user = $userRepository->create([
        'name'  => $registerForm->name,
        'email' => $registerForm->email,
        'passwordHash' => $provider->hash($registerForm->password)
    ]);
    $user->save();

    // Вручную его логиним
    $provider->setUser($user);
    return true;
}

Единственное что надо заметить это то что мы добавили скрытое поле register в HTML код формы, по какому проверяем логин это или регистрация.

Состояние проекта на этом этапе (Коммит 5)

6. Социальный логин

Теперь подключим логин из Facebook и Twitter. Начнем с того что добавим два поля facebookId и twitterId к таблице пользователей создав новую миграцию:

/* /assets/migrate/migrations/2_social_login.sql */

ALTER TABLE users ADD COLUMN twitterId VARCHAR(255) AFTER passwordHash;

-- statement

ALTER TABLE users ADD COLUMN facebookId VARCHAR(255) AFTER twitterId;

Теперь нам надо создать приложение на этих платформах и получить appId и appSecret. При регистрации правильно указываем Callback Url: http://localhost.com/socialAuth/callback/twitter для Twitter и http://localhost.com/socialAuth/callback/twitter для Facebook. Сами эти роуты ми создадим позже, а пока пропишем настройки:

// /assets/config/social.php

return [
    'facebook' => [
        'type'      => 'facebook',
        'appId'     => 'YOUR APP ID',
        'appSecret' => 'YOUR APP SECRET'
    ],
    'twitter' => [
        'type'           => 'twitter',
        'consumerKey'    => 'YOUR APP ID',
        'consumerSecret' => 'YOUR APP SECRET'
    ]
];

И включим поддержку социального логина в уже знакомом нам конфиге auth.php:

// /assets/config/auth.php
<?php

return [
    'domains' => [
        'default' => [
            // ....
            'providers'  => [
                //.....

                // Включаем соцлогин
                'social' => [
                    'type' => 'social.oauth',

                    // После логина запоминаем пользователя в сессию
                    'persistProviders' => ['session']
                ]
            ]
        ]
    ]
];

Все, с настройками закончили, возьмемся за код. Помните как нам надо было имплементировать интерфейс в классах репозитории и сущности пользователя для логина по паролю? Теперь туда добавится еще один:

namespace ProjectAppORMUser;

// ....

/** Этот интерфейс позволяет логинится через соцсети */
use PHPixieAuthSocialRepository as SocialRepository;

class UserRepository extends Repository implements LoginUserRepository, SocialRepository
{
    // ....

    /**
     * Находит пользователя по его данным полученным из компонента Social.
     * Если пользователь не нашелся то возвращает null.
     *
     * @param SocialUser $socialUser
     * @return User|null
     */
    public function getBySocialUser($socialUser)
    {
        // Получаем имя поле в базе в котором хранится социальное id пользователя,
        // например twitterId or facebookId
        $providerName = $socialUser->providerName();
        $field = $this->socialIdField($providerName);

        // И делаем поиск по этому полю
        return $this->query()->where($field, $socialUser->id())->findOne();
    }

    /**
     * Получает имя поля с социальным id.
     * В нашем случае ми делаем это просто добавляя 'Id' к имени провайдера
     *
     * @param string $providerName
     * @return string
     */
    public function socialIdField($providerName)
    {
        return $providerName.'Id';
    }
}

И теперь наконец сам новый процессор для авторизации:

namespace ProjectAppHTTPAuth;

use PHPixieAppORMUser;
use PHPixieAuthSocialProvidersOAuth as OAuthProvider;
use PHPixieHTTPRequest;
use ProjectAppORMUserUserRepository;
use ProjectAppHTTPProcessor;
use PHPixieSocialOAuthUser as SocialUser;

/**
 * Обрабатываем логин через соцсети
 */
class Social extends Processor
{
    /**
     * Переводит пользователя на внешнюю страницу логина
     * например Twitter или Facebook
     *
     * @param Request $request HTTP request
     * @return mixed
     */
    public function defaultAction($request)
    {
        $provider = $request->attributes()->get('provider');

        // Если параметр провайдера пуст, то делаем редирект на страницу логина
        if(empty($provider)) {
            return $this->redirect('app.processor', ['processor' => 'auth']);
        }

        // Создаем URL на внешнюю страницу и делаем редирект
        $callbackUrl = $this->buildCallbackUrl($provider);
        $url = $this->oauthProvider()->loginUrl($provider, $callbackUrl);
        return $this->responses()->redirect($url);
    }

    /**
     * Обрабатываем коллбек от провайдера.
     * Это и будет та страница http://localhost.com/socialAuth/callback/twitter
     * которую мы указали при регистрации нашего приложения.
     *
     * @param Request $request HTTP request
     * @return mixed
     */
    public function callbackAction($request)
    {
        $provider = $request->attributes()->getRequired('provider');

        // Опять строим URL страницы коллбека, это нужно при получении токена
        $callbackUrl = $this->buildCallbackUrl($provider);
        $query = $request->query()->get();

        // И вот сама обработка
        // Если такой пользователь уже существует в базе то он сразу залогинится.
        // В любом случае при успешной авторизации мы получим его соц данные в $userData
        $userData = $this->oauthProvider()->handleCallback($provider, $callbackUrl, $query);

        // Если что-то пошло не так, например пользователь отклонил авторизацию
        // то редиректим его назад на страницу логина
        if($userData === null) {
            return $this->redirect('app.processor', ['processor' => 'auth']);
        }

        // Если же авторизация прошла успешно, но он гн залогинился
        // значит это он первый раз на сайте и его надо зарегисторировать
        if($this->user() === null) {
            $user = $this->registerNewUser($userData);

            // И после регистрации сразу залогинить
            $this->oauthProvider()->setUser($user);
        }

        // Теперь он точно залогинен, так что возвращаем его на главную страницу
        return $this->redirect('app.frontpage');
    }

    /**
     * Регистрация новго пользователя из соцсети
     *
     * @param SocialUser $socialUser
     * @return mixed
     */
    protected function registerNewUser($socialUser)
    {
        /** @var UserRepository $userRepository */
        $userRepository = $this->components()->orm()->repository('user');

        // Получаем имя пользователя с его соц данных.
        // Поскольку у разных провайдеров это поле может быть разное,
        // то для этого создаем отдельный метод.
        $profileName =  $this->getProfileName($socialUser);

        // Получаем имя поля куда сохранит его соц id
        $socialIdField = $userRepository->socialIdField($socialUser->providerName());

        // И создаем пользователя
        $user = $userRepository->create([
            'name'         => $profileName,
            $socialIdField => $socialUser->id()
        ]);
        $user->save();

        return $user;
    }
    /**
     * Получам имя пользователя из его соц данных
     *
     * @param SocialUser $socialUser
     * @return mixed
     */
    protected function getProfileName($socialUser)
    {
        // В нашем у Twitter и Facebook поле имени одинаковое, так что все просто.
        return $socialUser->loginData()->name;
    }

    /**
     * Строим URL для коллбека, куда соцсеть 
     * перенаправит к нам пользователя после авторизации.
     *
     * @param $provider
     * @return string
     */
    protected function buildCallbackUrl($provider)
    {
        return $this->frameworkHttp()->generateUri('app.socialAuthCallback', [
            'provider' => $provider
        ])->__toString();
    }

    /**
     * Получаем проавайдер OAuth авторизации
     *
     * @return OAuthProvider
     */
    protected function oauthProvider()
    {
        $domain = $this->components()->auth()->domain();
        return $domain->provider('social');
    }
}

Дальше прописываем роуты и добавляем ссылки логина в нашу форму авторизации:

// /bundles/app/assets/templates/login.php

<?php $url = $this->httpPath('app.socialAuth', ['provider' => 'twitter']); ?>
<a class="btn btn-lg btn-primary btn-block" href="<?=$url?>">Login with Twitter</a>

<?php $url = $this->httpPath('app.socialAuth', ['provider' => 'facebook']); ?>
<a class="btn btn-lg btn-primary btn-block" href="<?=$url?>">Login with Facebook</a>

Состояние проекта на этом этапе (Коммит 6)

7. Добавление сообщений

Тут собственно нет ничего интересного, еще одна форма, только в этот раз через AJAX для разнообразия. Тут единственное что надо отметить
так это использование блоков в шаблонах для добавления скриптов. И так мы добавляем блок scripts в родительский шаблон:

<!-- /bundles/app/assets/templates/layout.php -->

<!-- Позволить подшаблонам добавлять свои скрипты в конец страницы -->
<?=$this->block('scripts')?>

Теперь в самом шаблоне messages мы можем добавить скрипт в этот блок:

<!-- /bundles/app/assets/templates/messages.php -->

<?php $this->startBlock('scripts'); ?>
    <script>
        $(function() {
            // Init the form handler
            <?php $url = $this->httpPath('app.action', ['processor' => 'messages', 'action' => 'post']);?>
            $('#messageForm').messageForm("<?=$_($url)?>");
        });
    </script>
<?php $this->endBlock(); ?>

В тот же блок можно добавить контент несколько раз, в результате он будет выведен в порядке добавления. Кстати можно сделать и наоборот,
добавлять контент только в том случае если блок пуст, рассмотрим два примера:

<?php $this->startBlock('test'); ?>
Hello
<?php $this->endBlock(); ?>

<?php $this->startBlock('test'); ?>
World
<?php $this->endBlock(); ?>

<?=$this->block('test')?>
<!-- Получим -->
Hello
World

<!--
    Если второй параметр true и блок уже существует, то функция startBlock()
    просто возвратит false а следовательно все что внутри if не выполнится.
-->
<?php if($this->startBlock('test', true)): ?>
Hello
<?php $this->endBlock();endif; ?>

<?php if($this->startBlock('test', true)): ?>
World
<?php $this->endBlock();endif; ?>

<?=$this->block('test')?>
<!-- Получим -->
Hello

Еще посмотрим что возвращает новый экшн процессора Messages после того как было создано новое сообщение:

 public function postAction($request)
 {
    // ....

    // Возвратим ORM сущность как простой PHP объект.
    // Параметр true так же указывает что надо рекурсивно превратить и подгруженные связи, 
    // но в данном случае их нет.
    //
    // А дальше PHPixie автоматически превращает объекты и массивы в JSON при выводе.
    return $message->asObject(true);
 }

**Состояние проекта на этом этапе (Коммит 7)

8. Консольные команды

Теперь добавим две консольные команды. Тут все по аналогии:

namespace ProjectAppConsole;

use PHPixieConsoleCommandConfig;
use PHPixieSliceData;
/**
 * Листинг сообщений в консоли
 */
class Messages extends Command
{
    /**
     * Настройка команды
     * @param Config $config
     */
    protected function configure($config)
    {
        // Описание
        $config->description("Print latest messages");

        // Добавляем опцию для фильтра по id пользователя
        $config->option('userId')
            ->description("Only print messages of this user");

        // Добавляем аргумент для контроля количества сообщений
        $config->argument('limit')
            ->description("Maximum number of messages to display, default is 5");
    }
    /**
     * @param Data $argumentData
     * @param Data $optionData
     */
    public function run($argumentData, $optionData)
    {
        // считываем параметр количества
        $limit = $argumentData->get('limit', 5);

        // Строим запрос
        $query = $this->components()->orm()->query('message')
            ->orderDescendingBy('date')
            ->limit($limit);

        // Если указан userId то добавляем условие к запросу
        $userId = $optionData->get('userId');
        if($userId) {
            $query->relatedTo('user', $userId);
        }

        // Получаем массив сообщений
        $messages = $query->find(['user'])->asArray();

        // Если их не нашлось
        if(empty($messages)) {
            $this->writeLine("No messages found");
        }

        // Выводим сообщения
        foreach($messages as $message) {
            $dateTime = new DateTime($message->date);
            $this->writeLine($message->text);
            $this->writeLine(sprintf(
                "by %s on %s",
                $message->user()->name,
                $dateTime->format('j M Y, H:i')
            ));
            $this->writeLine();
        }
    }
}

namespace ProjectAppConsole;

use PHPixieConsoleCommandConfig;
use PHPixieDatabaseDriverPDOConnection;
use PHPixieSliceData;

/**
 * Выводит статистику по сообщениям
 */
class Stats extends Command
{
    /**
     * Настройка команды
     * @param Config $config
     */
    protected function configure($config)
    {
        $config->description("Display statistics");
    }

    /**
     * @param Data $argumentData
     * @param Data $optionData
     */
    public function run($argumentData, $optionData)
    {
        // Получаем компонент Database
        $database = $this->components()->database();

        /** @var Connection $connection */
        $connection = $database->get();

        // Считаем все сообщения
        $total = $connection->countQuery()
            ->table('messages')
            ->execute();

        $this->writeLine("Total messages: $total");

        // Получаем статистику по каждому пользователю
        $stats = $connection->selectQuery()
            ->fields([
                'name' => 'u.name',
                // sqlExpression позволяет добавить сырой SQL
                'count' => $database->sqlExpression('COUNT(1)'),
            ])
            ->table('messages', 'm')
            ->join('users', 'u')
                ->on('m.userId', 'u.id')
            ->groupBy('u.id')
            ->execute();

        foreach($stats as $row) {
            $this->writeLine("{$row->name}: {$row->count}");
        }
    }
}

Не забываем прописать их в классе ProjectAppConsole:

namespace ProjectApp;

class Console extends PHPixieDefaultBundleConsole
{
    /**
     * Here we define console commands
     * @var array
     */
    protected $classMap = array(
        'messages' => 'ProjectAppConsoleMessages',
        'stats'    => 'ProjectAppConsoleStats'
    );
}

Готово, теперь посмотрим как они выглядят в консоли:

# ./console

Available commands:

app:messages                  Print latest messages
app:stats                     Display statistics

# ....

# ./console help app:messages

app:messages [ --userId=VALUE ] [ LIMIT ]
Print latest messages

Options:
userId    Only print messages of this user

Arguments:
LIMIT    Maximum number of messages to display, default is 5

# ./console help app:stats

app:stats
Display statistics

И сами результаты роботы:

# ./console app:messages 2

Simplicity is the ultimate sophistication. -- Leonardo da Vinci
by Trixie on 7 Dec 2016, 16:40

Simplicity is prerequisite for reliability. -- Edsger W. Dijkstra
by Trixie on 7 Dec 2016, 15:05

# ./console app:stats

Total messages: 14
Pixie: 3
Trixie: 11

Состояние проекта на этом этапе (Коммит 8)

9. Использование параметров конфигурации

Для того чтобы все параметры зависящие от сервера отделить от самой конфигурации, можно использовать параметризацию.
Все очень просто, создаем файл /assets/parameters.php в котором лежат параметры, а затем ссылаемся на них из конфигов.

// /assets/parameters.php

return [
    'database' => [
        'name'     => 'phpixie',
        'user'     => 'phpixie',
        'password' => 'phpixie'
    ],

    'social' => [
        'facebookId'     => 'YOUR APP ID',
        'facebookSecret' => 'YOUR APP SECRET',

        'twitterId'     => 'YOUR APP ID',
        'twitterSecret' => 'YOUR APP SECRET',
    ]
];

И теперь изменяем сами конфиги:

// /assets/config/database.php

return [
    // Database configuration
    'default' => [
        // Ссылки на параметры в /assets/parameters.php
        'database' => '%database.name%',
        'user'     => '%database.user%',
        'password' => '%database.password%',
        'adapter'  => 'mysql',
        'driver'   => 'pdo'
    ]
];

// /assets/config/social.php

return [
    'facebook' => [
        'type'      => 'facebook',
        'appId'     => '%social.facebookId%',
        'appSecret' => '%social.facebookSecret%'
    ],
    'twitter' => [
        'type'           => 'twitter',
        'consumerKey'    => '%social.twitterId%',
        'consumerSecret' => '%social.twitterSecret%'
    ]
];

Теперь при деплойменте на сервер достаточно только заменить этот один файл. Кстати поскольку это просто PHP код то в нем
можно использовать и привычные конструкции типа if и switch чтобы отдавать разные параметры в зависимости от сервера.

Финальный код

Конец

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

А если вы хотите помочь проекту то можете поставить нам звездочку на гитхабе :)

Автор: jigpuzzled

Источник

Поделиться

* - обязательные к заполнению поля