Пишем простой веб сервис на языке программирования Rust
Я новичок в языке Rust, но он быстро становится моим любимым языком программирования. Хотя написание небольших проектов на Rust обычно менее эргономично и занимает больше времени (по крайней мере, со мной за рулём), это бросает вызов тому, как я думаю о дизайне программы. Мои бои с компилятором становятся менее частыми, после того как я узнаю что-то новое.
Я работаю над дополнением zigbee2mqtt Hass.io,
это расширение Домашний помощник
для платформы домашней автоматизации. Надстройка опирается на библиотеку
zigbee2mqtt
. zigbee2mqtt
довольно новый проект,
который быстро развивается и ещё не имеет опубликованных релизов. Дополнения на Hass.io
распространяются в виде Docker-контейнеров и zigbee2mqtt
просто использует самую свежую ветку
master
базовой библиотеки при сборке контейнера. При таком подходе возникла проблема:
когда новые коммиты были перенесены в zigbee2mqtt
, пользователи дополнения не могли
обновиться до последней версии, пока контейнер дополнения не был собран
(что происходит автоматически в Travis CI только тогда, когда коммиты были
перенесены в репозиторий add-on). Мне нужен был способ запускать сборку
на Travis всякий раз, когда библиотека была изменена на Github.
Почему бы не реализовать это на языке Rust?
В этом посте я пройдусь по созданию простого веб-сервиса в Rust с помощью actix-web
,
который принимает входящие сообщения Github webhook
и запускает сборку Travis CI
через Travis API V3
.
Перехват Github Webhook
Как только webhooks
настроены, Github API v3
передаёт
PushEvent
на указанный URL с полезной нагрузкой в формате JSON
с большим количеством
информации о коммите (описано в документации). Для целей этого примера
мы действительно заботимся только о поле ref
, из которого мы можем получить информацию о ветке:
1 2 3 4 5 6 | /// входящий PushEvent из Github Webhook. #[derive(Deserialize)] struct PushEvent { #[serde(rename = "ref")] reference: String, } |
serve
помогает нам десериализовать полезную нагрузку в структуру Push Event
и экстракторы из actix_web
дают весьма лёгкий способ доступа к данным JSON
в обработчике функции.
Функция-обработчик может принять некоторый тип Json<T>
в качестве аргумента,
и тело запроса будет автоматически десериализовано в тип T
,
пока он реализует типаж Deserialize
из serde
:
1 2 3 | use actix_web::Json; fn index(push: Json<PushEvent>) -> ... |
Внутри обработчика тело запроса автоматически десериализуется в аргумент push
, который имеет тип PushEvent
.
Проверка заголовков с помощью Middleware
Github webhooks
может сопровождаться секретным значением для аутентификации.
Если секретное значение указано, то оно использовано для создания HMAC-SHA1
тела запроса
и отправляться с запросом через заголовок "X-Hub-Signature
в формате sha1=<HMAC>
.
Мы можем реализовать middleware
с actix_web
, чтобы проверить этот заголовок для каждого запроса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | use actix_web::HttpRequest; use actix_web::middleware::{Middleware, Started}; use actix_web::error::{ErrorUnauthorized, ParseError}; use actix_web::Result; struct VerifySignature; impl<S> Middleware<S> for VerifySignature { fn start(&self, req: &mut HttpRequest<S>) -> Result<Started> { use std::io::Read; let r = req.clone(); let s = r.headers() .get("X-Hub-Signature") .ok_or(ErrorUnauthorized(ParseError::Header))? .to_str() .map_err(ErrorUnauthorized)?; // получаем "sha1=" из заголовка let (_, sig) = s.split_at(5); let secret = env::var("GITHUB_SECRET").unwrap(); let mut body = String::new(); req.read_to_string(&mut body) .map_err(ErrorInternalServerError)?; if is_valid_signature(&sig, &body, &secret) { Ok(Started::Done) } else { Err(ErrorUnauthorized(ParseError::Header)) } } } |
Функция is_valid_signature
определена как у @aergonaut (в этом блоге более подробно).
Используя пакет crytpo
, мы сравниваем подпись в заголовке X-Hub-Signature
,
которую мы рассчитываем из тела запроса и нашего секретного значения.
Единственное существенное различие заключается в том, как строится шестнадцатеричная строка (не требует unsafe
кода)
и как обрабатывается префикс sha1=
.
Спасибо /u/vbrandl на Reddit
, который объяснил мне как это работает.
Использование Travis API
Travis V3 API предоставляет
конечную точку /repo/{slug|id}/requests
, которая позволяет запускать новые сборки.
Исходя из документации, вот основной запрос, который нам нужно реализовать:
1 2 3 4 5 6 7 8 9 10 11 12 13 | body='{ "request": { "message": "Override the commit message: this is an api request", "branch":"master" }}' curl -s -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -H "Travis-API-Version: 3" \ -H "Authorization: token xxxxxx" \ -d "$body" \ https://api.travis-ci.com/repo/danielwelch%2Fhassio-zigbee2mqtt/requests |
В hyper
есть хороший макрос, который позволяет нам определять пользовательские заголовки,
сохраняя при этом безопасность типов.
1 | header! { (TravisAPIVersion, "Travis-API-Version") => [u16] } |
Теперь мы можем добавить наши заголовки и тело JSON
reqwest::RequestBuilder
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | #[derive(Serialize)] struct TravisRequest { message: String, branch: String, } fn travis_request(url: &str) -> Result<reqwest::Response> { let client = reqwest::Client::new(); let res = client .post(url) .header(reqwest::header::ContentType::json()) .header(TravisAPIVersion(3)) .header(reqwest::header::Authorization(auth_str())) .json(&TravisRequest { message: "API Request triggered by zigbee2mqtt update".to_string(), branch: "master".to_string(), }) .send() .map_err(ErrorInternalServerError)?; Ok(res) fn auth_str() -> String { format!("token {}", std::env::var("TRAVIS_TOKEN").unwrap()).to_owned() } |
Проектирование реализации типажа Responder
В actix-web
, обработчик просто должен реализовать Handler,
который уже реализован для любой функции, которая принимает HttpRequest
и возвращает типаж Responder.
Json<T>
реализует FromRequest
, который преобразует HttpRequest
в Json<T>
за сценой.
Взяв первую часть нашего определения функции выше, мы можем теперь закончить её, зная, что нам нужно вернуть.
1 | fn index(push: Json<PushEvent>) -> impl Responder {} // очень причудливый и новый `impl Trait` в выходном значении функции |
Все, что осталось сделать, это реализовать типаж Responder
в соответствии с документацией.
Это будет сообщение в формате JSON, которое будет отправлено в ответ на успешный запрос.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | use actix_web::{Responder, HttpRequest, HttpResponse, Error}; #[derive(Serialize)] struct ServerMessage(String) impl Responder for ServerMessage { type Item = HttpResponse; type Error = Error; fn respond_to<S>(self, _req: &HttpRequest<S>) -> Result<HttpResponse, Error> { let body = serde_json::to_string(&self)?; Ok(HttpResponse::Ok() .content_type("application/json") .body(body)) } } |
Объединение всех элементов вместе:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | use std::env; use actix_web::{Json, Responder, HttpRequest, HttpResponse, Error}; use actix_web::error::ErrorInternalServerError; fn index(push: Json<PushEvent>) -> impl Responder { let travis_url = env::var("TRAVIS_URL").unwrap(); if push.reference.ends_with("master") { match travis_request("https://api.travis-ci.org/repo/19145006/requests") { Ok(_) => ServerMessage(format!( "PushEvent on branch master found, request sent to {}", travis_url).to_owned()), Err(e) => ErrorInternalServerError(e), } } else { ServerMessage("PushEvent is not for master branch".to_owned()) } } |
Это не сработает. Компилятор жалуется на несоответствие типов в операторе match.
Документация actix-web
предлагает использовать Either
,
чтобы вернуть два разных типа.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | use actix_web::Either; type ServerResponse = Either<ServerMessage, Error>; fn index(push: Json<PushEvent>) -> impl Responder { let travis_url = env::var("TRAVIS_URL").unwrap(); if push.reference.ends_with("master") { match travis_request("https://api.travis-ci.org/repo/19145006/requests") { Ok(_) => Either::A( ServerMessage(format!( "PushEvent on branch master found, request sent to {}", travis_url) .to_owned()) ), Err(e) => Either::B(ErrorInternalServerError(e)), } } else { Either::A(ServerMessage("PushEvent is not for master branch".to_owned())) } } |
Это работает, но мне это не нравится. Это уродливо и кажется слишком сложным для такого простого случая.
Должен быть лучший способ, чем этот, чтобы вернуть сериализованное сообщение или HTTP ошибку от ресурса actix-web
.
И, прочитав больше документации, я обнаружил, что есть лучшие способы.
Я был заинтересован в том, чтобы использовать мою реализацию типажа Responder
для ServerMessage
и сохранить большую часть логики обработчика ответов, привязанной к этой struct
,
потому что это заставило меня чувствовать себя круто.
По сути, моя реализация типажа Responder
в методе respond_to
уже готов вернуть в Result
либо HttpResponse
или Error
.
Почему мы не обрабатываем ошибку в структуре SeverMessage
и её реализации ответа?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | #[derive(Serialize)] struct ServerMessage { message: String, // нам не нужно сериализовать ошибку, // она будет передана в // составе структуры `ServerMessage` в `HTTPResponse` #[serde(skip_serializing)] e: Option<Error>, } impl Responder for ServerMessage { type Item = HttpResponse; type Error = Error; fn respond_to<S>(self, _req: &HttpRequest<S>) -> Result<HttpResponse, Error> { if self.e.is_some() { return Err(self.e.unwrap()); } else { let body = serde_json::to_string(&self)?; Ok(HttpResponse::Ok() .content_type("application/json") .body(body)) } } } |
Таким образом, ошибка может быть захвачена в struct
и обработана во время ответа.
Ошибка определяется в какой-то другой момент цикла запрос-ответ — это не имеет значения,
где и что ошибка, пока это actix-web::Error
. Добавление некоторых методов для удобства,
чтобы сделать вещи немного чище…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | impl ServerMessage { fn success<T: ToString>(s: T) -> ServerMessage { ServerMessage { message: s.to_string(), e: None, } } fn error(e: Error) -> ServerMessage { ServerMessage { message: "".to_owned(), e: Some(e), } } } |
И наш конечный вариант выглядит намного лучше:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | fn index(push: Json<PushEvent>) -> impl Responder { let travis_url = env::var("TRAVIS_URL").unwrap(); if push.reference.ends_with("master") { match travis_request("https://api.travis-ci.org/repo/19145006/requests") { Ok(_) => ServerMessage::success(format!( "PushEvent on branch master found, request sent to {}", travis_url )), Err(e) => ServerMessage::error(e), } } else { ServerMessage::success("PushEvent is not for master branch") } } |
Всё, что осталось сделать, это запустить сервер в main.rs
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | /// функция utility из примера проекта `heroku buildpack` fn get_server_port() -> u16 { env::var("PORT") .ok() .and_then(|p| p.parse().ok()) .unwrap_or(8080) } fn main() { use std::net::{SocketAddr, ToSocketAddrs}; let sys = actix::System::new("updater"); let addr = SocketAddr::from(([0, 0, 0, 0], get_server_port())); server::new(|| { App::new() .middleware(HeaderCheck) .resource("/", |r| r.method(http::Method::POST).with(index)) }).bind(addr) .unwrap() .start(); let _ = sys.run(); } |
Заключение
Оказывается, проще было просто предоставить автору zigbee2mqtt
доступ на запись
и заставить его запустить сборку через скрипт after_success
в этом репозитории.
Тем не менее, это было забавное упражнение в основах нового веб-фреймворка Rust,
и некоторые новые концепции, которые он вводит.
Исходные коды проекта данной статьи смотрите здесь