15 Июня 2018 • оригинал: Daniel Welch • перевод: Александр Андреев • обучение • поддержите на Patreon

Я новичок в языке 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 (); }

Заключение