Разработка клиентской части блога на DVA.Часть 3

В прошлых двух частях  мы разработали простые страницы (часть 1) нашего приложения и создали главную страницу (часть 2). В этой статье мы рассмотрим более интересные кейсы:

  • Подключение к API, разработанному на Loopback;
  • Разработка авторизации на JWT (json web token).

Подключение к API, разработанному на Loopback

Итак, наконец-то мы добрались до момента работы с настоящим api, предоставляемым nodejs приложением. Для начала нам необходимо сделать две вещи:

  • Добавить возможность переключения api с mock данных на nodejs приложение и обратно;
  • Добавить настройку для проксирования запросов на nodejs приложение.

Однако, Вам еще понадобится загрузить доработанную версию loopback приложения. В данном приложении мы переключили хранение данных на MySQL базу данных  в моделях, добавили автомиграции и заполнение тестовыми данными. Код приложения доступен в репозитории:

git clone https://github.com/dionic-i/lb-blog.git

После загрузки переходим в папку проекта:

npm i
git checkout part2-2
npm run dev

Чтобы добавить возможность переключения api с mock данных на nodejs приложение и обратно давайте добавим файл конфигурации в папку utils, где укажем текущий префикс api и два разных набора api. Вот что у нас получилось:

const APIV1 = '/api';

const config = {
  name: 'Our blog',

  apiPrefix: 'api_lp',

  api: {
    userLoginUrl   : `${APIV1}/user/login`,
    userLogoutUrl  : `${APIV1}/user/logout`,
    statusUrl      : `${APIV1}/status`,
    blogsUrl       : `${APIV1}/blogs`,
    blogByIdUrl    : `${APIV1}/blogs/{id}`,
    blogArticlesUrl: `${APIV1}/blogs/{id}/articles`,
    articleUrl     : `${APIV1}/articles/${id}`,
  },

  api_lp: {
    userLoginUrl   : `${APIV1}/Users/login`,
    userLogoutUrl  : `${APIV1}/Users/logout`,
    statusUrl      : `${APIV1}/Apps/status`,
    blogsUrl       : `${APIV1}/Blogs`,
    blogByIdUrl    : `${APIV1}/Blogs/{id}`,
    blogArticlesUrl: `${APIV1}/Blogs/{id}/articles`,
    articleUrl     : `${APIV1}/Articles/{id}`,
  },
};

export default config;

Теперь нам необходимо немного доработать методы в файлах сервисов blogs.js и articles.js, поправив их методы по загрузке данных. Рассмотрим на примере одного из методов:

import request from '../utils/request';
import config from '../utils/config';
import _ from 'lodash';

const {apiPrefix} = config; (1)
const {blogsUrl, blogByIdUrl, blogArticlesUrl} = config[apiPrefix]; (2)

export async function findById(id) {
  const url = _.replace(blogByIdUrl, '{id}', id);
  return request({
    url : url,
    method: 'get'
  });
}

В данном случае мы сначала импортировали объект конфигурации, затем сделали деструктуризацию и получили текущий apiPrefix (1). А потом опять провели деструтуризацию, но уже объекта со списком url и получили в переменные наши url для запроса данных (2). Их мы уже и применяем в самих методах.

Теперь, чтобы наше клиентское приложение обращалось к nodejs api за данными, нам необходимо добавить настройку для проксирования запросов. Делается это добавлением следующих строчек в файл .webpackrc:

{
  "proxy": {
     "/api": {
       "target": "http://127.0.0.1:3000",
       "changeOrigin": true,
     }
  },
  "extraBabelPlugins": [
    ["import", { "libraryName": "antd", "style": true }]
  ],
  "env": {
    "development": {
      "extraBabelPlugins": [
        "dva-hmr"
       ]
     }
  }
}

Все! Теперь наше клиентское приложение обращается за данными к nodejs приложению. Если будет необходимо переключиться обратно на mock данные, то мы просто меняем apiPrefix и убираем проксирование данных из .webpackrc.

Скачать исходный код можно по ссылке:

git clone https://github.com/dionic-i/lb-blog-client.git

После загрузки переходим в папку проекта

npm i
git checkout part2-2
npm run dev

Разработка авторизации на JWT (json web token)

Мы добрались до самой интересной части нашей статьи  - авторизации пользователя по JWT. 

Loopback из коробки поддерживает методы для авторизации пользователей. У модели User есть для этого соответствующие методы: login и logout, которые доступны по url /Users/login и Users/logout. Остальные методы, которые есть у данной модели можно посмотреть в explorer.

Метод /Users/login в качестве параметров принимает username и password и возвращает json токен, который используется для дальнейшей авторизации в приложении.

Вот так выглядит успешный ответ при авторизации:

{
  "id": "ek33csbgM77V48fpiYSlFy7GyH8tZm5MTCQBA6UzXrGGL3arSbRS5IQBnlfYUy0N",
  "ttl": 1209600,
  "created": "2018-03-01T10:48:38.585Z",
  "userId": 1 
}

Нам необходимо будет сохранить этот токен (id) и в дальнейшем отправлять его в качестве параметра при каждом запросе. Сохранить его можно в cookies или localStorage. Мы сохраним его в куке под названием authorized. А затем при каждом запросе будем его добавлять в качестве query параметра к каждому запросу. Для тех страниц, где требуются права авторизованного пользователя, это откроет доступ. Конечно, если пользователь успешно прошел авторизацию.

Давайте определим что нам предстоит сделать:

  • Добавить модель с методом авторизации, проверки статуса пользователя и выхода из приложения (app);
  • Добавить сервис, который будет отправлять запросы на сервер по авторизации, получении данных текущего пользователя и т.д.;
  • Изменить верхнее меню приложения, добавив кнопки для входа и выхода из приложения;
  • Добавить страницу для прохождения авторизации;
  • Добавить новый роут.

Для начала следует сказать, что в последней версии приложения на loopback добавлен один новый метод: метод проверки статуса пользователя, который возвращает информацию о пользователе (/Apps/status). Мы его сделали методом модели App, которая имеет только этот метод и не подключена к базе данных. Если пользователь не авторизирован, то метод возвращает пустой объект:

{
  "currentUser":
  {
    "id":0,"realname":"",
    "username":"",
    "email":"",
    "permissions":[],
    "auth":false
  },
  "success":true
}

Итак, модель приложения (app).

import {routerRedux} from 'dva/router';
import {login, logout, status} from '../services/app';
import Cookies from 'js-cookie';

export default {
namespace: 'app',

  state: {
    user: {},
    auth: {},
  },

  subscriptions: 
    setup ({dispatch}) {
      dispatch({type: 'status'});
    },
  },

  effects: {
    * login ({payload,}, {put, call, select}) {
      const auth = yield call(login, payload);

      const {id, ttl, created, userId, success} = auth;

      if (success) {
        // Устанавливаем cookies токена.
        Cookies.set('authorized', auth.id, {expires: 7});
        yield put({type: 'updateState', payload: {id, ttl, created, userId}});
      }

      yield put({type: 'status'});

      yield put(routerRedux.push('/'))
  },

  * status ({payload,}, {call, put, select}) {
    const {success, currentUser: user} = yield call(status, payload);
    if (success && user) {
    yield put({
      type : 'updateState',
      payload: {
        user
      },
    });
    }
  },

  * logout ({payload}, {call, put}) {
    const data = yield call(logout, payload);

    if (data.success) {
      Cookies.remove('authorized');
      yield put({type: 'status'});
    }
    else {
      throw (data);
    }
  }

  },

  reducers: {

  updateState (state, {payload}) {
    return {
      ...state,
      ...payload
    }
  }
}

В данной модели у нас имеется один синхронный метод, который просто обновляет state переданными значениями и три асинхронных - авторизация (login), выход из приложения (logout) и запроса данных пользователя (status). Важной особенностью данной модели является метод setup, который при первой ее загрузке запускает отправку запроса о статусе пользователя на сервер. И когда он будет выполнен мы получим в состоянии нашего приложения информацию о текущем пользователе или ее отсутствие. После создания модели ее надо подключить как и все модели в index файле нашего приложения:

app.model(require('./models/app').default);

Модель готова и подключена к приложению. Настало время внести небольшие правки в компонент меню. Сначала переделаем маппинг state в компонент. В отличии от того как мы делали раньше, в данной модели мы еще добавили два селектора:

  • isAuth - проверяет авторизован ли пользователь;
  • getUsername - возвращает имя пользователя, если он авторизован.
const mapStateToProps = state => {
  return {
    menu : state.menu,
    isAuth : isAuth(state),
    username: getUsername(state),
  }
};

export default connect(mapStateToProps)(MyMenu);

Сами селекторы - достаточно простые функции, которые используются для получения какой-либо информации из нашего глобального state. В данном случае isAuth проверяет авторизорован ли пользователь, а getUsername - получает имя пользователя:

export const isAuth = (state) => {
  const {app} = state;
  const {user} = app;
  return user && user.id && user.id !== 0;
};

export const getUsername = (state) => {
  const {app} = state;
  const {user} = app;
  return user && user.username ? user.username : '';
};

Мы их сохранили в файле /selectors/app.js. 

В методе render компонента Menu мы добавим две константы:

  • первая для отображения кнопки "Вход" или "Выход";
  • вторая для отображения имени пользователя.

Заметьте, что селекторы, которые мы отправили в компонент меню, уже вызываются, как просто свойства объекты props:

const style = {float: 'right'};

const authItem = !me.props.isAuth
  ? (
    <Menu.Item key="login" style={style}>
      <NavLink activeClassName="active" to="/login">Вход</NavLink>
    </Menu.Item>
  )
  :(
    <Menu.Item key="logout" style={style}>
      <span onClick={me.onLogout}>Выход</span>
    </Menu.Item>
  );

const profile = (
  <Menu.Item key="username" style={style}>
    <span>{me.props.username}</span>
  </Menu.Item>
);

Добавим эти две константы в return поле кнопки для отображения блогов. Кнопка "Вход" отправляет нас на новый роут, где мы в дальнейшем отобразим форму для авторизации, а кнопка "Выход" по событию onClick будет вызывать метод компонента onLogout, в котором мы вызовем действие logout нашей модели app:

onLogout = (e) => {
  e.preventDefault();
  const {dispatch} = this.props;
  dispatch({type: 'app/logout'});
};

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

import request from '../utils/request';
import config from '../utils/config';

const {apiPrefix} = config;
const {userLoginUrl, userLogoutUrl, statusUrl} = config[apiPrefix];

export async function login(params) {
  return request({
    url : userLoginUrl,
    method: 'post',
    data : params,
  })
}

export async function logout(params) {
  return request({
    url : userLogoutUrl,
    method: 'post',
    data : params,
  })
}

export async function status(params) {
  return request({
    url : statusUrl,
    method: 'get',
    data : params,
  })
}

Нам осталось только сделать саму страницу авторизации и подключить ее к нашему роутингу. Назовем ее LoginPage. Эта страница будет содержать в себе компонент формы для авторизации LoginForm. Нам больше интересен сам код компонента LoginForm. В данном компоненте мы будет использовать формы, которые нам предоставляет фреймворк ant.design. Он содержит довольно широкое api для работы с формами. Для того, чтобы использовать все возможности по работе с формами, нам надо завернуть наш компонент формы в декоратор Form.create():

export default connect(({app}) => ({app}))(Form.create()(LoginForm));

Для добавления роута в наше приложение немного подправим файл router.js, добавив еще один роут ко всем остальным:

<Route path="/login" exact component={LoginPage}/>

 

Давайте напоследок рассмотрим, как происходит авторизация в приложении по пунктам для большей ясности.

1. Как только приложение загружается, наша модель отправляет запрос на урл /Apps/status, откуда получает информацию о пользователе. Если пользователь авторизован, то придет нужная информация, если же нет, то придет пустой объект. 

2. Допустим, пользователь не авторизован, тогда мы нажимаем кнопку войти и попадаем на страницу авторизации, где вводим имя пользователя и пароль.

3. После нажатия кнопки приложение отправляет запрос на урл /Users/login и при успешной авторизации получает токен, который сохраняет в куке (authorized).

4. Также после этого отправляется запрос на /Apps/status для получении информации о текущем пользователе.

5. Данные о пользователе попадают в компонент Menu и он меняется: скрывает кнопку "Вход" и отображает имя пользователя и кнопку "Выход".

6. Также после успешной авторизации пользователь отправляется на главную страницу приложения.

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

Скачать исходный код, как всегда, можно по ссылке:

git clone https://github.com/dionic-i/lb-blog-client.git

После загрузки переходим в папку проекта:

npm i
git checkout part2-3
npm run dev

Успехов!

  dva

  Смотреть все посты