Создание REST API с помощью AdonisJs и TDD, часть 1

я недавно играл с Адонис фреймворк NodeJS MVC, который очень похож на Ларавель очень популярный PHP-фреймворк. Мне действительно начал нравиться подход Adonis, больше условностей, чем конфигурации. Мне также нравится то, что они говорят в заголовке.

Writing micro-services or you are a fan of TDD, it all boils down to confidence. AdonisJs simplicity will make you feel confident about your code.

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

Об этом руководстве

Итак, в этом уроке мы создадим своего рода список фильмов для просмотра. Пользователь может создать вызов и поместить в него фильмы. Я знаю, что это не самый крутой проект, но он поможет вам увидеть, как Lucid, Adonis ORM работает с отношениями. Мы также увидим, как легко этот фреймворк сделает нашу жизнь.

В конце этого урока мы создадим сервис, в котором пользователь, наконец, сможет ввести только название фильма и год. Мы будем использовать TheMovieDB API и найти информацию об этом фильме.

Начиная

Сначала нам нужно установить Adonis cli

npm i -g @adonisjs/cli

Чтобы убедиться, что все работает, запустите команду в своем терминале

adonis --help

Если вы видите список команд, это означает, что это работает 😃

Для создания проекта мы запустим эту команду в терминале

adonis new movies_challenges --api-only

Здесь это создаст новый вызов проекта movies_challenges и это будет шаблон только для API, так что с этим не будет пользовательского интерфейса.

Следуй инструкциям

cd movies_challenges

Для запуска проекта команда будет

adonis serve --dev

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

Откройте проект в текстовом редакторе по вашему выбору. Для себя использую VSCode это бесплатно и здорово.

Настройте БД

Адонис настроил для нас много всего. Но они позволяют нам выбирать некоторые вещи, например, какую базу данных использовать и т. д. Если вы откроете файл config/database.js ты увидишь sqlite, mysql а также postgresql конфиг. Для этого проекта я буду использовать Posgresql.

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

npm i --save pg

После этого войдите в свой .env файл и настроить соединение для вашей БД. Для меня это будет выглядеть

DB_CONNECTION=pg
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_DATABASE=movies_challenges_dev

После того, как я убедился, что создаю базу данных из своего терминала

createdb movies_challenges_dev

Настройка среды тестирования

Adonis не поставляется с готовым фреймворком для тестирования, но заставить его работать очень просто.

Запустите команду

adonis install @adonisjs/vow

Что это ? У Adonis есть способ установить зависимость с помощью внутреннего использования npm. Но вся прелесть в том, что они могут добавлять и другие вещи. Например, если вы посмотрите, что произойдет после того, как это будет сделано, они откроют URL-адрес в вашем браузере с другими инструкциями.

Они создали 3 новых файла.

.env.testing
vowfile.js
example.spec.js

Сначала мы настроим .env.testing файл, чтобы убедиться, что это тестовая БД, а не dev.

Добавьте это в конец файла

DB_CONNECTION=pg
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_DATABASE=movies_challenges_test

После того, как я убедился, что создаю базу данных из своего терминала

createdb movies_challenges_test

Пишем свой первый тест

Таким образом, приложение будет работать так, что у пользователя может быть много проблем. У этих вызовов может быть много фильмов. Но фильм может быть многим вызовом.

Итак, в отношениях это будет выглядеть так

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

Мы будем использовать это в будущем.

Итак, для создания вашего первого тестового файла нам нужно подумать о том, что нам нужно сделать.

Первое, что я хочу проверить, это тот факт, что пользователь может создать вызов. Задача должна иметь название, а описание не является обязательным. Я хочу убедиться, что только аутентифицированный пользователь может создать вызов. Когда задача создается, мне нужно поместить идентификатор current_user в данные. Так мы узнаем, кто хозяин.

Адонис дает нам множество инструментов, облегчающих нашу жизнь. Одним из них является команда генератора благодаря ace. Мы будем использовать команду, чтобы сделать наш первый тест. Но чтобы сделать это, нам нужно зарегистрировать тестовую среду vow у поставщика проекта. Открытым start/app.js и добавьте это в свой aceProvider

const aceProviders = [
  '@adonisjs/lucid/providers/MigrationsProvider',
+ '@adonisjs/vow/providers/VowProvider'
]

Теперь мы можем запустить команду

adonis make:test CreateChallenge

Когда вы получите модуль запроса или функциональный тест, используйте функциональный и нажмите «Ввод».

Это создаст файл test/functional/create-challenge.spec.js.

Хороший первый тестовый файл 😃

Мы изменим название этого теста, чтобы сделать его более полезным.

test('can create a challenge if valid data', async ({ assert }) => {})

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

test('can create a challenge if valid data', async ({ assert }) => {

  const response = 

  response.assertStatus(201)
  response.assertJSONSubset({
    title: 'Top 5 2018 Movies to watch',
    description: 'A list of 5 movies from 2018 to absolutely watched',
    user_id: 
  })
})

Здесь я проверяю, что я хочу получить от моего API-вызова 201 created с определенным объектом, у которого будет заголовок, описание, которое я предоставляю, и мой текущий идентификатор пользователя.

Далее нам нужно написать код для ответа

const { test, trait } = use('Test/Suite')('Create Challenge')

trait('Test/ApiClient')

test('can create a challenge if valid data', async ({ assert, client }) => {

  const data = {
    title: 'Top 5 2018 Movies to watch',
    description: 'A list of 5 movies from 2018 to absolutely watched'
  }

  const response = await client.post('/api/challenges').send(data).end()

  response.assertStatus(201)
  response.assertJSONSubset({
    title: data.title,
    description: data.description,
    user_id: 
  })
})

Чтобы сделать вызов API, нам нужно сначала импортировать trait из набора тестов. Нам нужно сказать тесту, что нам нужен клиент API. Теперь это даст нам доступ к client в обратном вызове. Затем я помещаю свои данные, которые я хочу, в объект и отправляю их на маршрут с помощью глагола POST.

Теперь я хочу протестировать текущий пользователь jwt в заголовках. Как мы можем это сделать ? Это так просто с Adonis

'use strict'

const Factory = use('Factory')
const { test, trait } = use('Test/Suite')('Create Challenge')

trait('Test/ApiClient')
trait('Auth/Client')

test('can create a challenge if valid data', async ({ assert, client }) => {
  const user = await Factory.model('App/Models/User').create()

  const data = {
    title: 'Top 5 2018 Movies to watch',
    description: 'A list of 5 movies from 2018 to absolutely watched',
  }

  const response = await client
    .post('/api/challenges')
    .loginVia(user, 'jwt')
    .send(data)
    .end()

  response.assertStatus(201)
  response.assertJSONSubset({
    title: data.title,
    description: data.description,
    user_id: user.id,
  })
})

МОЙ БОГ !!! Слишком много. НЕ ВОЛНУЙСЯ. Нам просто нужно немного разбить его. Итак, сначала о том, что такое Factory. Фабрика — это способ упростить фиктивные данные. Это идет с действительно хорошим API. Здесь Factory создаст пользователя для базы данных. Но как фабрика может узнать данные, которые нам нужны? Просто откройте database/factory.js файл и добавьте это внизу

const Factory = use('Factory')

Factory.blueprint('App/Models/User', faker => {
  return {
    username: faker.username(),
    email: faker.email(),
    password: 'password123',
  }
})

Здесь мы создаем фабрику для пользователя моделей, который у нас есть в базе данных. Это также использует подделку, которая является библиотекой, которая значительно упрощает фиктивные данные. Здесь я указал поддельное имя пользователя и адрес электронной почты. Но почему я не делаю этого с паролем? Это потому, что когда мне нужно будет проверить вход в систему, я хочу иметь возможность войти в систему, и поскольку пароль станет хэшем, мне нужно знать, какова исходная версия.

Итак, эта линия

const user = await Factory.model('App/Models/User').create()

Мы создаем пользователя для бд, теперь мы можем использовать этого же пользователя здесь, в запросе

const response = await client
  .post('/api/challenges')
  .loginVia(user, 'jwt')
  .send(data)
  .end()

Как видите, теперь мы можем использовать loginVia и передавать пользователя в качестве первого аргумента, второй аргумент — это тип аутентификации, здесь я говорю jwt. я могу использовать .loginVia причина этой черты вверху

trait('Auth/Client')

Теперь в моем ответе json я могу проверить, что идентификатор пользователя действительно является идентификатором текущего пользователя.

response.assertJSONSubset({
  title: data.title,
  description: data.description,
  user_id: user.id,
})

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

Поэтому мы добавим эту строку перед утверждением

console.log('error', response.error)

Теперь мы можем запустить тест командой adonis test

Вы увидите ошибку

error: relation "users" does not exist

Что это значит ? Это потому, что Vow по умолчанию не запускает миграцию. Но мы, разработчик, не хотим запускать его вручную при каждом тесте, что будет болезненно. Что мы можем сделать ? Адонис снова делает нашу жизнь легкой. Перейти в файл vowfile.js и раскомментируйте уже написанный для этого код

В строке 14 const ace = require('@adonisjs/ace')
В строке 37 await ace.call('migration:run', {}, { silent: true })
В строке 60 await ace.call('migration:reset', {}, { silent: true })

Теперь, если вы повторно запустите тест, вы увидите

error { Error: cannot POST /api/challenges (404)

Хороший шаг вперед 😃 Эта ошибка означает, что у нас нет маршрута. Нам нужно его создать. Открытым start/routes.js и добавьте этот код

Route.post('/api/challenges', 'ChallengeController.store')

Вот я и говорю, когда мы получаем почтовый запрос на маршрут /api/challenges передать данные контроллеру ChallengeController и хранилищу методов. Помните, что Adonis — это MVC, поэтому да, нам нужен контроллер. 😃

Сохраните код и перезапустите тест

Теперь в тексте ошибки вы увидите

Error: Cannot find module \'/Users/equimper/coding/tutorial/movies_challenges/app/Controllers/Http/ChallengeController\'

Это означает, что контроллер не существует 😃 Поэтому нам нужно создать его. Опять же у адониса есть генератор для этого

adonis make:controller ChallengeController

При запросе выберите http, а не websocket

Повторите тест

'RuntimeException: E_UNDEFINED_METHOD: Method store missing on App/Controllers/Http/ChallengeController\n> More details: 

Хранилище методов отсутствует. Хорошо, это нормально, контроллер пуст. Добавьте это в свой файл app/Controllers/Http/ChallengeController.js

class ChallengeController {
  store() {}
}

Повторите тест

expected 204 to equal 201
204 => 201

Итак, вот где начинается самое интересное, мы ожидали 201, но получили 204. Мы можем исправить эту ошибку, добавив

class ChallengeController {
  store({ response }) {
    return response.created({})
  }
}

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

 expected {} to contain subset { Object (title, description, ...) }
  {
  + title: "Top 5 2018 Movies to watch"
  + description: "A list of 5 movies from 2018 to absolutely watched"
  + user_id: 1
  }

Здесь ошибка означает, что мы отправляем только ожидаемые данные. Теперь пора заняться логикой.

const Challenge = use('App/Models/Challenge')

class ChallengeController {
  async store({ response, request }) {
    const challenge = await Challenge.create(
      request.only(['title', 'description'])
    )

    return response.created(challenge)
  }
}

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

Теперь, если я перезапущу тест, я увижу

'Error: Cannot find module \'/Users/equimper/coding/tutorial/movies_challenges/app/Models/Challenge\''

Хорошо понятно, что модели не существует

adonis make:model Challenge -m

-m также дает вам файл миграции

Эта команда будет создана

✔ create  app/Models/Challenge.js
✔ create  database/migrations/1546449691298_challenge_schema.js

Теперь, если мы вернем тест

'error: insert into "challenges" ("created_at", "description", "title", "updated_at") values ($1, $2, $3, $4) returning "id" - column "description" of relation "challenges" does not exist'

Имеет смысл, что в таблице нет описания столбца. Поэтому мы должны добавить один

Итак, откройте файл миграции для challenge_schema.

class ChallengeSchema extends Schema {
  up() {
    this.create('challenges', table => {
      table.text('description')
      table.increments()
      table.timestamps()
    })
  }

  down() {
    this.drop('challenges')
  }
}

Здесь я добавляю столбец text описание вызова

Повторите тест

'error: insert into "challenges" ("created_at", "description", "title", "updated_at") values ($1, $2, $3, $4) returning "id" - column "title" of relation "challenges" does not exist'

Теперь та же ошибка, но для заголовка

class ChallengeSchema extends Schema {
  up() {
    this.create('challenges', table => {
      table.string('title')
      table.text('description')
      table.increments()
      table.timestamps()
    })
  }

  down() {
    this.drop('challenges')
  }
}

Здесь title будет строкой. Теперь перезапустите тест

  expected { Object (title, description, ...) } to contain subset { Object (title, description, ...) }
  {
  - created_at: "2019-01-02 12:28:37"
  - id: 1
  - updated_at: "2019-01-02 12:28:37"
  + user_id: 1
  }

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

Снова в файл миграции добавить

class ChallengeSchema extends Schema {
  up() {
    this.create('challenges', table => {
      table.string('title')
      table.text('description')
      table
        .integer('user_id')
        .unsigned()
        .references('id')
        .inTable('users')
      table.increments()
      table.timestamps()
    })
  }

  down() {
    this.drop('challenges')
  }
}

Здесь user_id является целым числом, ссылайтесь на идентификатор пользователя в таблице пользователей.

Теперь откройте модель Challenge в app/Models/Challenge.js и добавьте этот код

class Challenge extends Model {
  user() {
    this.belongsTo('App/Models/User')
  }
}

И нам нужно сделать другой способ отношений таким открытым app/Models/User.js и добавить внизу после токенов

challenges() {
  return this.hasMany('App/Models/Challenge')
}

Ничего себе, мне нравится этот синтаксис и то, как легко мы можем видеть отношения. Спасибо команде Adonis и Lucid ORM 😃

Запустить тест

 expected { Object (title, description, ...) } to contain subset { Object (title, description, ...) }
  {
  - created_at: "2019-01-02 12:35:20"
  - id: 1
  - updated_at: "2019-01-02 12:35:20"
  + user_id: 1
  }

Та же ошибка? Да, когда мы создаем, мы не помещаем user_id. Поэтому нам нужно

class ChallengeController {
  async store({ response, request, auth }) {
    const user = await auth.getUser()

    const challenge = await Challenge.create({
      ...request.only(['title', 'description']),
      user_id: user.id,
    })

    return response.created(challenge)
  }
}

Здесь я использую auth, объект, который мы используем для аутентификации. Здесь я могу использовать текущего пользователя с помощью функции auth.getUser. Это вернет пользователя из jwt. Теперь я могу объединить это с объектом при создании.

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

Добавьте в наш тестовый файл

test('cannot create a challenge if not authenticated', async ({
  assert,
  client,
}) => {})

Мы снова будем работать с той же идеей, сначала создавая утверждение и двигаясь назад.

test('cannot create a challenge if not authenticated', async ({
  assert,
  client,
}) => {
  response.assertStatus(401)
})

Здесь мы хотим, чтобы статус был 401 неавторизованный

test('cannot create a challenge if not authenticated', async ({
  assert,
  client,
}) => {
  const data = {
    title: 'Top 5 2018 Movies to watch',
    description: 'A list of 5 movies from 2018 to absolutely watched',
  }

  const response = await client
    .post('/api/challenges')
    .send(data)
    .end()

  console.log('error', response.error)

  response.assertStatus(401)
})

Сначала обязательно удалите console.log из другого теста. Теперь ваш тест должен выглядеть вот так.

Откройте файл маршрутов

Route.post('/api/challenges', 'ChallengeController.store').middleware(['auth'])

Если вы запустите тест, все будет зеленым 😃

Но теперь я хотел бы проверить тот факт, что требуется заголовок, а описание и заголовок должны быть строкой, как я могу это сделать?

Adonis дает нам доступ к еще одному действительно хорошему инструменту валидатора.

Нам нужно установить библиотеку валидатора

adonis install @adonisjs/validator

Перейти к start/app.js и добавить провайдера

const providers = [
  '@adonisjs/framework/providers/AppProvider',
  '@adonisjs/auth/providers/AuthProvider',
  '@adonisjs/bodyparser/providers/BodyParserProvider',
  '@adonisjs/cors/providers/CorsProvider',
  '@adonisjs/lucid/providers/LucidProvider',
+  '@adonisjs/validator/providers/ValidatorProvider'
]

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

test('cannot create a challenge if no title', async ({ assert }) => {})

Прежде чем идти дальше, мне не нравится тот факт, что мне нужно вручную написать заголовок и описание. Я хотел бы иметь возможность сделать так, чтобы фабрика создавала его для нас. Это возможно, сначала перейдите к database/factory.js

Нам нужно создать Фабрику для Вызова

Factory.blueprint('App/Models/Challenge', faker => {
  return {
    title: faker.sentence(),
    description: faker.sentence()
  }
}

Теперь мы можем использовать это с помощью make

const { title, description } = await Factory.model(
  'App/Models/Challenge'
).make()

Это даст нам поддельный заголовок и описание, но без сохранения в БД.

Вернувшись к тесту, вы получите сообщение об ошибке, если заголовок не находится в теле.

test('cannot create a challenge if no title', async ({ assert, client }) => {
  response.assertStatus(400)
  response.assertJSONSubset([
    {
      message: 'title is required',
      field: 'title',
      validation: 'required',
    },
  ])
})

Теперь нам нужно написать код, чтобы добраться до этого. Я пропущу какой-то процесс, но продолжайте, так мы становимся лучше. Я просто не буду писать это, потому что это займет много строк 😃

test('cannot create a challenge if no title', async ({ assert, client }) => {
  const user = await Factory.model('App/Models/User').create()
  const { description } = await Factory.model('App/Models/Challenge').make()

  const data = {
    description,
  }

  const response = await client
    .post('/api/challenges')
    .loginVia(user, 'jwt')
    .send(data)
    .end()

  response.assertStatus(400)
  response.assertJSONSubset([
    {
      message: 'title is required',
      field: 'title',
      validation: 'required',
    },
  ])
})

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

Во-вторых, я получаю поддельное описание от моей фабрики. Я просто посылаю это.

Я утверждаю, что получаю 400 за неверный запрос и массив сообщений об ошибках в формате json.

Если я запущу тест сейчас, я получу

expected 201 to equal 400
  201 => 400

Это означает, что задача создается, но не должна

Поэтому нам нужно добавить валидатор для этого

adonis make:validator CreateChallenge

Зайдите в свой файл маршрутов, и мы хотим использовать это

Route.post('/api/challenges', 'ChallengeController.store')
  .validator('CreateChallenge')
  .middleware(['auth'])

Теперь, если вы запустите тест, вы увидите

expected 201 to equal 400
  201 => 400

Имеет смысл ломать валидатор. Время написать код. Открытым app/Validators/CreateChallenge.js

class CreateChallenge {
  get rules() {
    return {
      title: 'required|string',
      description: 'string',
    }
  }

  get messages() {
    return {
      required: '{{ field }} is required',
      string: '{{ field }} is not a valid string',
    }
  }

  get validateAll() {
    return true
  }

  async fails(errorMessages) {
    return this.ctx.response.status(400).json(errorMessages)
  }
}

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

Если вы запустите тест сейчас, все должно работать 😃

Мы также можем добавить поле notNullable в столбец title в миграции.

table.string('title').notNullable()

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

test('cannot create a challenge if title and description are not a string', async ({
  assert,
  client
}) => {
  const user = await Factory.model('App/Models/User').create()

  const data = {
    title: 123,
    description: 123
  }

  const response = await client
    .post('/api/challenges')
    .loginVia(user, 'jwt')
    .send(data)
    .end()

  response.assertStatus(400)
  response.assertJSONSubset([
    {
      message: 'title is not a valid string',
      field: 'title',
      validation: 'string'
    },
    {
      message: 'description is not a valid string',
      field: 'description',
      validation: 'string'
    }
  ])
})

И если мы снова запустим тест BOOM, все будет зеленым.


Конечное слово

Это из моего блога здесь

Код можно найти здесь на гитхаб

Похожие записи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *