REST API на Sinatra

Мы научились работать с чужими API, настало время научиться писать свое собственное.

Sinatra - это уже нечто большее, чем библиотека, это фреймворк.

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

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

Вообще если говорить про сетевой стек, то его можно разделить на 3 модуля:

  • Rack - контракт или интерфейс, описывающий как должен вести себя сервер. Rack не содержит в себе никакого кода для работы сети, он лишь говорит, что "если мы дернем метод А, то получим Б", в то время как реализация этого метода А, каким образом мы будем получать Б, это уже задача другого компонента-реализации

  • Rack реализация - по другому еще называют Rack-совместимый сервер. Это уже готовая библиотека с реализацией сетевых протоколов, которые являются реализацией методов Rack. Сюда также входит логика организации многопоточных обработок, чтобы наш сервер мог обрабатывать не по 1 запросу от пользователя, а десятки, сотни или даже тысячи запросов от пользователей. К таким реализациям можно отнести гемы thin, puma, unicorn, falcon и другие

  • HTTP фреймворк - гем, который в своей основе использует Rack-совместимый сервер и предоставляет разработчику удобный способ взаимодействия с ним, так как в головом виде пользоваться Rack-совместимым сервером будет не так удобно, по сравнению с такими фреймворками как Sinatra или Rails

Теперь давайте добавим в наш Gemfile следующие гемы:

  • thin - простая реализация Rack сервера

  • sinatra - наш фреймворк для удобной работы с Rack сервером

После добавления гемов в Gemfile вызовите bundle install

Теперь создадим самый простой сервер с помощью нашего фреймворка:

require 'sinatra'

# sinatra предоставляет метод для конфигурации
# который принимает блок кода, внутри которого мы передаем необходимые настройки
# подробнее о конфигурации https://sinatrarb.com/configuration.html
configure do
  # set - метод принимающий 2 аргумента: ключ конфигурации и значение
  # в данном случае опускаются скобки для более красивого синтаксиса
  # указываем, что в качестве реализации rack сервера будет thin
  # если данную настройку не указывать, то sinatra будет использовать первый попавшийся сервер из устанолвенных гемов
  set :server, :thin
  # указываем какой порт наше приложение должжно слушать
  # если дпнную настройку не указывать, то будет использоваться порт 4567
  set :port, 8000
end

# Теперь мы указываем что:
# - наш сервер умеет обрабатывать GET метод
# - по относительному адресу - /, то есть корневой раздел
# Такие методы в простонародье называют 'ручкой'
get '/' do
  'hello world !' # А ответом на этот запрос будет строка Hello world !
end

запустим наш .rb файл, если все хорошо, то увидим сообщение Listening on localhost:8000, CTRL+C to stop

Теперь протестируем через клиент в VSCode, дернув нашу новосозданную ручку. В адрес ресурса указываем метод GET и путь localhost:8000, где localhost это синоним для локального IP адреса, а 8000 - порт, который слушает наше приложение.

Если в ответ на ваш запрос вы получили строку Hello World !, значит можно вас поздравить ! Вы запустили свое первое серверное приложение.

Теперь разберемся как нам научиться принимать части относительного пути. Создадим новую 'ручку':

# :value позволяет нам делать наш относительный путь более эластичным
get '/person/:name/:age' do
  # в качестве ключа в params мы передаем название, которое указали в пути
  name = params[:name]
  # помимо символа мы можем использовать и обычную строку
  age = params['age']
  "Hello, #{name}. You are #{age} y.o."
end

Теперь попробуем отправить запрос по адресу localhost:8000/person/john/23 и получим ответ:

Hello, john. You are 23 y.o.

Отлично.

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

А что на счет параметров, которые передаются после вопросительного знака ? Sinatra по умолчанию считает все такие параметры необязательными, поэтому они нигде не объявляются в коде. При этом сами параметры мы можем получить точно так же как и куски пути. Например:

# :value позволяет нам делать наш относительный путь более эластичным
get '/person/:name/:age' do
  # в качестве ключа в params мы передаем название, которое указали в пути
  name = params[:name]
  # помимо символа мы можем использовать и обычную строку
  age = params['age']
  country = params[:country]
  "Hello, #{name} from #{country}. You are #{age} y.o."
end

А теперь если мы отправим запрос на адрес localhost:8888/person/John/23?country=Russia, то мы увидим сообщение:

Hello, John from Russia. You are 23 y.o.

Хотя ни в каком пути мы параметр country не объявляли

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

Для начала создадим объект-хранилище. Такие объекты принято называть DTO (Data Transfer Object) и если дословно перевести, то сразу становится понятно, что такие объекты выполняют только одну функцию - транспортировка данных.

require 'json'

class Person
  def initialize(name, age, country)
    @name = name
    @age = age
    @country = country
  end

  def to_json(*args)
    {
      'age':     @age,
      'name':    @name,
      'country': @country
    }.to_json(args)
  end
end

Теперь добавим зависимости в файл нашего сервера:

require 'json'
require 'sinatra'
require_relative 'person'

После чего соберем класс Person и вернем его в качестве ответа в формате JSON

get '/person/:name/:age' do
  age = params[:age]
  name = params[:name]
  country = params[:country]
  content_type :json # укажем в HTTP ответе заголовок content-type: application/json
  Person.new(name, age, country).to_json
end

Снова отправим запрос на localhost:8888/person/John/23?country=Russia и уже в ответ получим JSON:

{
  "age": "23",
  "name": "John",
  "country": "Russia"
}

Структура проекта

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

Пример: У нас есть интернет-магазин с товаром. Какие области отвественности могут быть у магазина ? Товар, заказ, личный кабинет и тд. Как будет выглядить наша структура проекта:

routers/
  init.rb
  orders.rb
  products.rb
  accounts.rb
server.rb
config.ru

При этом сам роутер будет выглядеть следующим образом:

# так как зависимость `sinatra` служит для объединения все своих зависимостей в одном месте
# то мы будет использовать более точечную завимость `sinatra/base`
# что позволит нам избежать добавления лишней функциональности
require 'sinatra/base'

# Наследуясь от Base класса мы получаем минимальный функционал для нашего роутера 
class Server < Sinatra::Base

  get 'orders/:id' do
    # какая-то логика
  end

  get 'orders' do
    # какая-то логика
  end

  delete 'orders/:id' do
    # какая-то логика
  end

  post 'orders' do
    # какая-то логика
  end
end

в то время как routes/init.rb будет служить файлом для объединения наших роутеров

require_relative 'orders'
require_relative 'products'
require_relative 'accounts'
# и так далее

А можно в динамике с помощью Dir вытащить пути до всех файлов в папке с файлом init.rb и автоматически их импортировать

# получаем относительный путь до нашего файла от места запуска программы
current_file = __FILE__ # В моем случае это 11_network/3_shop/routes/init.rb
# отбрасываем имя файла и оставляем только путь до папки с файлами init.rb, orders.rb и тд
current_dir = File.dirname(current_file) # В моем случае это 11_network/3_shop/routes

# с помощью несложной игры со строкой мы получаем цикл по всем файлам внутри директории routes
Dir["#{current_dir}/*.rb"].each do |file|
  # так как require может работать только с полным путем до файла
  # то мы получаем полный путь от корня до каждого файла находящегося в директории routes 
  # в моем случае это будет:
  #   /home/user/11_network/3_shop/routes/init.rb
  #   /home/user/11_network/3_shop/routes/orders.rb
  #   /home/user/11_network/3_shop/routes/products.rb
  #   /home/user/11_network/3_shop/routes/accounts.rb
  #   и тд 
  full_path_file = File.expand_path(file)
  # если название файла в текущей итерации не равно названию файла из которого запускается этот скрипт
  # значит мы его импортируем как зависимость
  # иначе если мы из init.rb будем импортировать init.rb, который будет импортировать init.rb и тд
  require full_path_file unless file == current_file
end

Файл server.rb содержит в себе класс который будет хранить в себе конфигурации для нашего Sinatra приложения, а также за счет run! if app_file == $PROGRAM_NAME мы сможем запускать наше приложение простым вызовом ruby path/to/server.rb.

require 'sinatra/base'

class Server < Sinatra::Base
  # вызываем метод run!, который будет запускаться, 
  # если путь до файла в команде `ruby path/to/server` будет равно пути до этого файла
  # в таком случае этот метод не будет вызываться если мы его просто импортируем в другом месте 
  run! if app_file == $PROGRAM_NAME
end
# После того как наше приложение настроено, происходит импорт файла init, 
# который в свою очередь импортирует все роутеры 
require_relative 'routers/init'

файл config.ru является конфигурационным файлом для утилиты rackup, а не через вызов ruby. Зачем ? Чтобы если мы захотим поменять сервер-имплементацию Rack спецификации или изменить порт для запуска приложения нам бы не пришлось менять исходный код, а всего лишь поменять значения в команде запуска

# импортируем наш application.rb файл
require File.expand_path('application', File.dirname(__FILE__))

# указываем rackup что ему нужно запустить для работы
run Application

И теперь простой командой запусаем наш сервер rackup путь/до/config.ru -s thin -p 8000. Как видим указание какой Rack совместимый сервер использовать, а также порт прослушиваемый приложением переехал из кода в командную строку.

Возможно у вас возник вопрос: А почему мы называем все классы одинаково и нарушаем правило имя_файла == ИмяКласса ? На самом деле это хитрость метапрограммирования из урока 9_professional/1_metaprogramming, которая позволяет за счет одинакового названия классов их смешать в один целый и большой класс.

Скорее всего эта часть урока вам могла показаться чересчур сложной за счет манипуляций с файлами и динамического импорта этих файлов, но если посидеть и разборать код по строчно, то все встанет на свои места через какое-то время изучения. Цель данных примеров - показать не только как организовать структуру проекта, но и какие сложные и в то же время простые вещи нам позволяет делать Ruby, так как при изучении Rails в дальнейшем нам часто придется сталкиваться с такими хитростями, которые реализовывает сам фреймворк.

Задание weather_info

Внимание! это задание необходимо разместить в директории модуля 11_network/weather_info

Сделайте из вашего консольного приложения серверное на базе фреймворка sinatra где:

  • адрес вашей ручки будет weathers, которая:

    • принимает страну в виде параметра запроса country

    • принимает город в виде параметра запроса city

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

  • Вернуть ответ в формате JSON

Пример запроса:

  • GET localhost:8000/weathers?country=Russia&city=Moscow

{
  "country": "Russia",
  "city": "Moscow",
  "created_at": "2023-03-23T09:22:11Z",
  "temperature_c": 1.1,
  "rain_mm": 0
}
  • GET localhost:8000/weathers?city=Astana

400 Bad Request - param country is empty

Рекоммендации:

  • Для разделения логики клиентов и севеерной логики, разместите классы клиентоав в отдельном каталоге clients

Дополнительный материал

Last updated