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

О чем будет рассказано в этой статье:

  • 1. Создание нового проекта AdonisJs
  • 2. Настройка приложения Heroku.
  • 3. Добавление новых маршрутов
  • 4. Добавление данных в базу данных
  • 5. Миграции
  • 6. Контроллеры
  • 7. Ясные модели
  • 8. CSRF
  • 9. Безопасность
  • 10. Проверка данных
  • 11. Тестирование

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

Установите adonis / CLI

npm i -g @adonisjs/cli

Создайте новый проект с любым именем, которое вам нравится

adonis new projectname

Это создаст adonis-fullstack-app

ДОКУМЕНТЫ:

…, Он поставляется с предварительно настроенными:

Bodyparser, Session, Authentication, промежуточное ПО веб-безопасности, CORS, Edge, механизм шаблонов, Lucid ORM, миграции и начальные числа.

Вы можете использовать флаг api-only для создания только сервера api. Но это уже другой рассказ.

// Scaffold project for api server
adonis new projectname --api-only 
adonis new projectname --api

Чтобы запустить проект локально и убедиться, что все работает должным образом, вы можете использовать этот сценарий (приложение будет запускаться с использованием .env производственного файла)

adonis serve --dev

Флаг --dev означает, что он будет перезапускаться каждый раз, если код был изменен, без этого флага вы должны перезапустить сервер вручную

Чтобы запустить adonis локально с другим env файлом (например, .env.dev), вы можете запустить такой скрипт

ENV_PATH=.env.dev adonis serve --dev

Перед развертыванием в Heroku мы внесем несколько изменений. В будущем вы поймете «почему»:

Добавьте пакеты mysql и url-parse npm в package.json

npm i mysql url-parse

Добавьте новые строки в /config/database.js файл:

//insert those line after Helpres
const Url = use('url-parse')
const CLEARDB_DATABASE_URL = new Url(Env.get('CLEARDB_DATABASE_URL'))
// and before module.exports {

Измените конфигурацию MySQL в /config/database.js файле:

mysql: {
  client: 'mysql',
  connection: {
    host: Env.get('DB_HOST', CLEARDB_DATABASE_URL.host),
    port: Env.get('DB_PORT', ''),
    user: Env.get('DB_USER', CLEARDB_DATABASE_URL.username),
    password: Env.get('DB_PASSWORD', CLEARDB_DATABASE_URL.password),
    database: Env.get('DB_DATABASE', CLEARDB_DATABASE_URL.pathname.substr(1))
  }
},

Надеюсь, вы знаете, как развернуть свой проект в git, поэтому я пропущу эту часть и буду считать, что вы это уже сделали.

2. Настройка приложения Heroku.

Создать новое приложение

Я предпочитаю выбирать автоматическое развертывание из ветки git master, но вы можете выбрать любой способ, который вам нравится.

После подключения вашего репозитория к приложению AdonisJs добавьте надстройку ClearDB MySQL в свое приложение Heroku.

На всякий случай - › дополнительная документация

После добавления надстройки ClearDB вы увидите новую добавленную переменную env CLEARDB_DATABASE_URL в вашем приложении Heroku. Как вы помните, я добавил строки кода, используя эту переменную env.

Затем добавьте переменные среды DB_CONNECTION и APP_KEY в настройки приложения Heroku. Значение APP_KEY вы можете найти в файле .env вашего созданного проекта AdonisJs.

Если вы не добавите APP_KEY, вы можете получить сообщение об ошибке:

RuntimeException: E_MISSING_ENV_KEY: Make sure to define environment variable APP_KEY.
App key is a randomly generated 16 or 32 characters long string required to encrypted cookies, sessions and other sensitive data.

Рекомендуется добавить еще две базы данных ClearDB в ваши надстройки Heroku, чтобы у вас было три среды:

  1. Производство с конфигурацией в .env файле;
  2. Staging с конфигурацией в .env.dev файле;
  3. Тестирование с конфигурацией в .env.testing файле;

3. Добавление новых маршрутов

Добавить новые строки в /start/routes.js файл.

Возврат простого текстового сообщения

Route.get('/simple-text', () => 'Hello Adonis')

В ответ на {heroku-host}/simple-text url вы получите текстовое сообщение «Hello Adonis». Просто не правда ли?

Возврат html-страницы

Route.on('/html-page').render('custom')

custom - это имя файла /resources/views/custom.edge (который представляет собой простой HTML-код с динамическими включениями, как в welcom.edge файле. Подробнее о файлах * .edge вы можете прочитать здесь

Динамические маршруты

Обязательные параметры

Route.get('dynamic/:id', ({ params }) => {
  return `Post ${params.id}`
})

Необязательные параметры

Route.get('dynamic/:drink?', ({ params }) => {
  // use Coffee as fallback when drink is not defined
  const drink = params.drink || 'Coffee'
return `One ${drink}, coming right up!`
})

Узнать больше - › Маршрутная документация

4. Добавление данных в базу данных

Добавить новые строки кода в /routes.js

// Database
const Database = use('Database')
Route.get('users/:username', async ({ params }) => {
  const userId = await Database
    .table('users')
    .insert({ username: params.username })
return userId
}).formats(['json'])

Если вы попытаетесь получить /users/custom-name, вы получите сообщение об ошибке

Error: insert into `users` (`username`) values ('custom-name') - ER_NO_DEFAULT_FOR_FIELD: Field 'email' doesn't have a default value

Ты знаешь почему?

Потому что мы забыли внести изменения в файл схемы, который был создан после adonis new projectname скрипта.

class UserSchema extends Schema {
  up () {
    this.create('users', table => {
      table.increments()
      table.string('username', 80).notNullable().unique()
      table.string('email', 254).notNullable().unique()
      table.string('password', 60).notNullable()
      table.timestamps()
    })
  }
down () {
    this.drop('users')
  }
}

Потому что после того, как мы добрались до /users/some-name, наша определенная схема UserSchema была создана в базе данных Heroku.

Не сердись 😠. Это было сделано намеренно, чтобы мы могли найти подходы к решению таких проблем :) 😉

Итак, нам нужно как-то исправить нашу схему в базе данных (внести изменения в обязательные поля email и password, которые в нашем случае не требуются)

В этом случае мы будем использовать миграции.

Из официальной документации:

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

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

5. Миграции

Создание схемы

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

adonis make:migration users
> Create table // for creating new table
  Select table // for altering old table
adonis migration:run // executes up() function defined in Schema

«Создать таблицу» и «Выбрать таблицу» имеют одно небольшое различие, и не более того:

Вы можете добавить переменную NODE_ENV перед сценарием (или ENV_PATH=/user/.env) для выполнения миграции в разных базах данных. Если вы установите для переменной NODE_ENV значение testing, AdonisJs попытается загрузить .env.testing файл из корня вашего приложения.

Например:

NODE_ENV=testing adonis migration:run

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

Я не рекомендую использовать sqlite для тестирования миграции, поскольку, как говорится в официальной документации KnexJS, о Alter () функция:

Это работает только в .alterTable () и не поддерживается SQlite или Amazon Redshift.

Мы создадим ту же версию нашей «производственной» базы данных ClearDB Heroku, но для «разработки».

Не забудьте скопировать переменную базы данных среды Heroku в .env.testing после создания новой «разработки» базы данных. (Имя переменной Env может отличаться от моего)

Но сначала проверим статус тестирования миграции, на всякий случай:

NODE_ENV=testing adonis migration:status

Мы увидим что-то вроде этого:

Давайте создадим нашу тестовую базу данных:

$ NODE_ENV=testing adonis migration:run

Если все прошло успешно, вы увидите сообщение Database migrated successfully

Если вы хотите удалить свою базу данных, запустите NODE_ENV=testing adonis migration:reset или NODE_ENV=testing adonis migration:rollback, если это было только одно действие

У нас есть два варианта: удалить столбец «электронная почта» из схемы или сделать его not required.

Первый вариант:

NODE_ENV=testing adonis make:migration user
> Select table

… С обновлением создано /database/migrations/***_user_schema.js file

class UserSchema extends Schema {
  up () {
    this.table('users', table => {
      table.dropColumn('email')
    })
  }
}

или второй вариант:

NODE_ENV=testing adonis make:migration user
> Select table

а также

class UserSchema extends Schema {
  up () {
    this.table('users', table => {
      table.string('email', 254).nullable().alter()
    })
  }
down () {
    this.table('users', table => {
      table.string('email', 254).notNullable().alter()
    })
  }
}

Если честно, мне нравится второй. Так что давай сделаем это.

NODE_ENV=testing adonis migration:run

И результат… База данных успешно перенесена

Рекомендация. При создании миграции всегда добавляйте логику для функции down (), чтобы ваш перенос работал и по-другому - ›когда вы выполняете migration:run и migration:rollback

Производство

Попробуем на «производственной» базе данных

adonis migration:run --force

И результат… База данных успешно перенесена!

Если вы хотите видеть запросы до того, как они будут выполнены, вы можете запустить

adonis migration:reset --force --log

Результат будет:

Queries for ***_user_schema.js
  alter table `users` modify `email` varchar(254) not null
Queries for ***_token.js
  drop table `tokens`
Queries for ***_user.js
  drop table `users`

Прежде чем вносить изменения в Heroku, давайте внесем одно изменение в наши маршруты.

6. Контроллеры

Замечательно, что он может легко добавить функцию обратного вызова с логикой в ​​наш routes.js вот так:

Route.get('users/:user/:password/:email', async ({ params }) => {
  const userId = await Database
    .table('users')
    .insert({
      username: params.username,
      password: params.password,
      email: params.email,
    });
const user = await User.find(userId)
Database.close(['mysql'])
return user
}).formats(['json'])

! Напоминание: НЕ используйте методы get для передачи личной информации пользователя. Это всего лишь пример.

Но с ростом нашего приложения файл /start/routes.js мог стать огромным.

Чтобы предотвратить эту ситуацию в будущем, AdodisJs предоставляет полезные функции, называемые Контроллеры привязки, поэтому наши маршруты могут быть максимально простыми, например:

Route.get('users', 'UserController.index')

index - это просто имя функции в UserController, которая должна выполняться, когда пользователь пытается достичь конечной точки (несколько примеров имен: store, show, edit, update, destroy). Чтобы узнать о других функциях контроллера, вы можете прочитать о них в документации.

class UserController {
  index () {
    return 'Some custom response'
  }  
}

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

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

adonis make:controller Users
Select controller type 
❯ For HTTP requests
  For Websocket channel

Будет создан новый app/Controllers/Http/UserController.js файл:

Итак, как будет выглядеть функция обратного вызова в UserController?

class UserController {
  customName ({ request }) {
    return {
      host: request.header('host'),
      url: request.url(),
      originalUrl: request.originalUrl(),
      method: request.method(),
      intended: request.intended(),
      ip: request.ip(),
      subdomains: request.subdomains(),
      'user-agent': request.header('user-agent'),
      accept: request.header('accept'),
      hello: request.header('hello'),
      isAjax: request.ajax(),
      hostname: request.hostname(),
      protocol: request.protocol()
    }
  }
...

и в нашем /start/routes.js файле

Route.get('request', 'UserController.customName').formats(['json'])

Теперь посмотрим на результат в Insomnia (или Почтальоне, если хотите).

7. Ясные модели

После того, как мы выясним, как это работает, давайте добавим несколько функций CRUD в наш UserController, используя Lucid Inserts and Updates.

Lucid - это реализация паттерна Active Record в Javascript. Если вы пришли из мира Laravel или Rails, возможно, вы знакомы с ним.

«Официальная документация»

Давайте обновим наш UserController новыми строками кода:

const User = use('App/Models/User')
class UserController {
  async index () {
    return await User.all()
  }
async show ({ params }) {
    return await User.find(params.id)
  }
async store () {}
  async update () {}  
  async destroy () {}
}

/routes.js :

Route.get('users', 'UserController.index').formats(['json'])
Route.get('users/:id', 'UserController.show').formats(['json'])

Результат получения Все пользователи и Пользователь по идентификатору:

Если получится пустой объект - ›не волнуйтесь. Это потому, что вы не можете добавлять пользователей в свою базу данных. Все работает, если вы просто получите статус ответа 200 «ОК».

Теперь давайте добавим модификацию операций с базой данных:

class UserController {
  ...
async store ({ request }) {
    const { username, password, email } = request.post()
    const user = new User()
    user.username = username
    user.password = password
    user.email = email
await user.save()
  }
async update ({ request, params }) {
    const user = await User.find(params.id)
const { username, password, email } = request.post()
user.username = username
    user.password = password
    user.email = email
await user.save()
  }
async destroy ({ params }) {
    const user = await User.find(params.id)
await user.delete()
  }
}

и в routes.js

Route.post('users', 'UserController.store').formats(['json'])
Route.patch('users/:id', 'UserController.update').formats(['json'])
Route.delete('users/:id', 'UserController.destroy').formats(['json'])

8. CSRF

Если вы попытаетесь достичь одной из трех ранее добавленных конечных точек и попытаетесь изменить базу данных, вы получите сообщение об ошибке:

403:Forbidden “HttpException: EBADCSRFTOKEN: Invalid CSRF token”

Это из-за части CSRF (подделка межсайтовых запросов).

ИЗ DOC: позволяет злоумышленнику выполнять действия от имени другого пользователя без его ведома или разрешения.

Если вы просто хотите отключить эту функцию, вы можете перейти к /config/shield.js файлу в своем проекте и изменить csrf.enable на false. (не рекомендуется, если вы используете не только конечные точки API)

Как настроить CSRF мы увидим в следующий раз. Теперь давайте посмотрим, как наши функции будут работать после отключения функции CSRF:

Отличная работа!

Но прежде чем мы поговорим о CSRF, давайте немного оптимизируем наши маршруты. Вы можете объединить все 5 пользовательских маршрутов CRUD в один с одинаковым результатом:

Route
  .resource('users', 'UserController')
  .apiOnly()

И даже сгруппируйте наши маршруты API с добавлением префикса:

Route.group(() => {
  Route.get('request', 'CustomController.someCustom')
    .formats(['json'])
Route
    .resource('users', 'UserController')
    .apiOnly()
}).prefix('api/v1/')

А теперь мы можем подробнее узнать о CSRF.

ИЗ DOC: AdonisJs защищает ваше приложение от атак CSRF, отклоняя неопознанные запросы. HTTP-запросы с методами POST, PUT и DELETE проверяются, чтобы убедиться, что эти запросы вызывают нужные люди из нужного места.

Промежуточное ПО Shield полагается на сеансы, поэтому убедитесь, что они настроены правильно.

Хорошая статья на эту тему: Следует ли использовать защиту CSRF на конечных точках Rest API?

Итак, как это «исправить»? Поскольку сейчас мы будем использовать наше приложение в качестве API, мы можем добавить filterUrls в конфигурацию /config/shield.js.

csrf: {
  enable: true,
  methods: ['POST', 'PUT', 'DELETE'],
  filterUris: ['/api/(.*)'],
  cookieOptions: {
    httpOnly: false,
    sameSite: true,
    path: '/',
    maxAge: 7200
  }
}

Библиотека @adonisjs/shield с файлом /config/shield.js будет НЕ включена, если вы создадите приложение с флагом —-api-only. Поэтому, если вы не заинтересованы в использовании файлов * .edge в качестве ответов на url-ы, вы можете отключить csrf.

9. Безопасность

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

Провайдер аутентификации AdonisJs поставляется с предустановленными шаблонами fullstack и api.

По умолчанию AdonisJs использует session для авторизации, давайте изменим его на JWT в /config/auth.js файле:

authenticator: 'jwt',
jwt: {
    algorithm: 'HS256', // by default
    serializer: 'lucid',
    model: 'App/Models/User',
    scheme: 'jwt',
    uid: 'email',
    password: 'password',
    options: {
        secret: Env.get('APP_KEY'),
        expiresIn: "1m", // 1 minute
    },
},

Затем измените наш /start/routes.js файл. Добавьте новый маршрут для /login пути и новую функцию цепочки middleware(‘auth’) к нашему старому /users маршруту:

// We don't want our logged-in user to access this
Route.
    post('login', 'AuthController.login').
    middleware('guest');
Route.
    resource('users', 'UserController').
    apiOnly().
    middleware('auth');

Создать новый AuthController:

adonis make:controller Auth

Как вы заметили, мы также должны добавить функцию входа в AuthController:

async login ({ auth, request }) {
    const { email, password } = request.post();
return auth.withRefreshToken().
        attempt(email, password);
}

Давай проверим. Во-первых, давайте проверим, что теперь мы не сможем получить всех пользователей из нашей базы данных:

И наш login:

Теперь нам нужно найти email и password некоторых пользователей, с которыми нужно войти. В моем случае это пользователь с адресом электронной почты [email protected] и паролем 123.

Как видно на скриншоте, password ранее хранился в базе данных со значением hashed. Это было сделано промежуточным программным обеспечением AdonisJs в файле /app/models/User.js.

/**
 * A hook to hash the user password before saving
 * it to the database.
 */
this.addHook('beforeSave', async (userInstance) => {
    if (userInstance.dirty.password) {
        userInstance.password = await Hash.make(userInstance.password);
    }
});

Поэтому, когда пользователь пытается аутентифицировать AdonisJs, хеши передают пароль и сравнивают его с хешированным паролем базы данных.

Итак, попробуем авторизоваться:

Я бы предпочел не копировать-вставить значения token и refreshToken в каждый запрос, где требуется авторизация каждый раз, когда я вхожу в систему, поэтому давайте воспользуемся некоторыми помощниками Insomnia:

И нет у нас двух вариантов установки значения токена для других запросов.

Во-первых, это установить token переменную env непосредственно в заголовки:

Во-вторых, использовать пункт меню Insomnia Auth, который будет делать то же самое:

Если вы добавите token переменную env в запрос Login, вы получите сообщение об ошибке:

Как вы помните, мы добавили middleware([‘guest’]) в login Route, что означает, что маршрут вернет ошибку, если пользователь уже вошел в систему.

Давайте протестируем нашу функцию авторизации с GET_ALL_USERS конечной точкой:

И поскольку мы ранее установили этот токен JWT expiresIn: 1m, если мы подождем одну минуту, а затем попытаемся получить всех пользователей, мы получим:

Давайте обновим функцию входа в AuthController для обновления функций токена:

async login ({ auth, request }) {
    const { refreshToken, email, password } = request.post();
if (refreshToken) {
        return await auth.
            generateForRefreshToken(refreshToken);
    }
    return auth.withRefreshToken().
        attempt(email, password);
}

И добавьте refreshToken в переменные среды Insomnia, как мы это делали ранее с token. После этого проверим результаты нашей работы:

Отличная работа!

10. Проверка данных

Для начала нам нужно добавить в наш проект @adonisjs/validator библиотеку:

adonis install @adonisjs/validator

Затем зарегистрируйте валидатор в файле /start/app.js:

const providers = [
  ...
  '@adonisjs/validator/providers/ValidatorProvider'
]

А после этого давайте воспользуемся некоторыми проверками в нашем store() методе UserController:

class UserController {
    async store({ request }) {
        const rules = {
            email: 'required|email|unique:users,email',
            password: 'required',
        };
const validation = await validate(request.post(), rules);
if (validation.fails()) {
            return validation.messages();
        }
...
    }

Давайте проверим это на создании нового User с неправильными email данными:

Проверка работает должным образом, но сообщения об ошибках… могли бы быть лучше. Мы можем корректировать сообщения об ошибках по типам проверки, в нашем случае по: required, email и unique:

const messages = {
    'email.required': 'Email is not present in request',
    'email.email': 'Enter a valid email address.',
    'email.unique': 'Email is already present in db',
};

И передайте messages в качестве третьего аргумента функции validate():

const validation = await validate(request.post(), rules, messages);

Функция Validate () останавливается при первой ошибке и возвращает ее. Но если мы хотим проверить все поля, которые мы передали, validateAll () здесь для спасения:

const { validateAll } = use('Validator');
...
const validation = await validateAll(request.post(), rules, messages);

Выглядит отлично, но в большинстве случаев проверки повторяются. Чтобы предотвратить это, мы можем создать новый Validator и использовать его как промежуточное ПО. Создадим один:

adonis make:validator StoreUser

Давайте заполним этот валидатор /app/Validators/StoreUser.js несколькими функциями:

class StoreUser {
    // use validateAll function instead of validate
    get validateAll () {
        return true;
    }
get rules () { 
        return {
            email: 'required|email|unique:users,email',
            password: 'required',
        };
    }
get messages () {
        return {
            'email.required': 'Email is not present in request',
            'email.email': 'Enter a valid email address.',
            'email.unique': 'Email is already present in db',
        };
    }
get sanitizationRules () {
        return {
            email: 'normalize_email',
        };
    }
async fails (errorMessages) {
        return this.ctx.response.send(errorMessages);
    }
}

Затем обновите User Route для использования StoreUser валидатора только в store функции в UserController:

Route
    .resource('users', 'UserController')
    .middleware('auth')
    .validator(new Map([
        [['users.store'], ['StoreUser']],
    ]))
    .apiOnly();

После этого вы можете удалить все ранее добавленные функции проверки в store функции UserController:

async store ({ request }) {
    const { username, password, email } = request.post();
    const user = new User();
    user.username = username;
    user.password = password;
    user.email = email;
await user.save();
}

И результат будет таким же. Круто, не правда ли?

Несколько слов о функции sanitizationRules в валидаторе StoreUser. Если пользователь передаст email в «странном» формате, таком как этот [email protected], он будет «дезинфицирован» на более «правильный» способ, например: [email protected].

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

11. Тестирование

Для тестирования нашего кода AdonisJs предоставляет @adonisjs/vow библиотеку

Вначале нам нужно его установить (он НЕ входит в состав строительных лесов)

adonis install @adonisjs/vow

Если скрипт будет выполняться должным образом, vowfile.js и /test, в корне вашего приложения будет создана папка с одним тестом:

test('make sure 2 + 2 is 4', async ({ assert }) => {
    assert.equal(2 + 2, 4);
});

После этого нам нужно прописать '@adonisjs/vow/providers/VowProvider' в файле /start/app.js:

const aceProviders = [
  ...
  '@adonisjs/vow/providers/VowProvider'
]

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

adonis test

Если все было настроено правильно, вы увидите сообщение PASSED после запуска этого сценария.

Теперь давайте добавим несколько тестов для нашего UserController.

adonis make:test UserController
  Unit test
> Functional test

Шаг за шагом:

  1. Мы создаем пользователя в нашей тестовой базе данных:
const user = await User.create({
        username: 'some',
        email: EMAIL,
        password: PASSWORD,
    });

2. Мы авторизуемся этим созданным пользователем:

const login = await client
        .post('api/v1/login')
        .send({
            email: EMAIL,
            password: PASSWORD,
        })
        .end();
 // You must call "end" to execute HTTP client requests.

3. Мы создаем нового пользователя, используя токен данных ответа в заголовке.

const response = await client
        .post('api/v1/users')
        .header('accept', 'application/json')
        .header('Authorization', `Bearer ${loginResponseJson.token}`)
        .send({
            username: 'someName',
            email: '[email protected]',
            password: '123',
        })
       .end();

4. Данные нашей базы данных вернутся после тестов.

trait('DatabaseTransactions');

5. Мы тестируем вызов API, как и в Insomnia.

trait('Test/ApiClient');

Это все! Молодец!

Спасибо за внимание!

Если у вас есть вопросы → с удовольствием отвечу 😉

Связанные истории









Если вам понравился этот рассказ, вы можете также проверить Список всех моих рассказов. Удачного кодирования 🎉