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