API-шлюз является важным компонентом современной микросервисной архитектуры, поскольку он выполняет посредническую функцию между клиентами и сервисами бэкенда. Он помогает направлять входящие запросы в соответствующие нисходящие сервисы на основе запрошенного пути, а также обеспечивает централизованный механизм управления безопасностью и аутентификацией.
В данной статье мы реализуем API-шлюз в Rust посредством библиотеки Hyper. В качестве нисходящих сервисов воспользуемся сервисами Kubernetes. Кроме того, для обработки аутентификации пользователя реализуем сторонний API авторизации.
Создание среды разработки
Прежде чем приступить к написанию кода, необходимо создать среду разработки. Воспользуемся Rust в качестве программного языка и библиотекой Hyper для обработки HTTP
-запросов. Кроме того, потребуются библиотеки serde
и yaml-rust
для работы с YAML
-файлами. Перечислим этапы создания среды.
- Установка Rust и Cargo по инструкциям с официального сайта Rust.
- Создание нового проекта Rust с использованием
cargo new <project-name>
. - Добавление необходимых зависимостей в файл
Cargo.toml
, как показано ниже. - Выполнение команды
cargo build
для загрузки и сборки зависимостей.
[dependencies]
serde = { version = "1.0.159", features = ["derive"] }
serde_yaml = "0.9.19"
tokio = { version = "1.16.1", features = ["full"] }
hyper = { version = "0.14.10", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
Реализация API-шлюза
Создав среду разработки, приступаем к реализации API-шлюза. Для лучшего понимания разобьем данный процесс на небольшие этапы.
Парсинг файла конфигурации
Воспользуемся YAML
-файлом для настройки API-шлюза. Данный файл содержит сопоставления путей с сервисами и URL
-адрес API авторизации Authorization
. Пример YAML
-файла:
---
authorization_api_url: "https://auth.example.com/api/v1/authorization"
services:
- path: "/users"
target_service: "http://user-service.default.svc.cluster.local"
target_port: "8080"
- path: "/orders"
target_service: "http://order-service.default.svc.cluster.local"
target_port: "8080"
Применяем библиотеки serde
и yaml-rust
для парсинга YAML
-файла. Ниже представлен код для выполнения этой задачи:
use serde::{Deserialize, Serialize};
use std::{fs::File, io::Read};
use serde_yaml;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServiceConfig {
pub path: String,
pub target_service: String,
pub target_port: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct GatewayConfig {
pub authorization_api_url: String,
pub services: Vec<ServiceConfig>,
}
pub fn load_config(path: &str) -> Config {
let mut file = File::open(path).unwrap();
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();
serde_yaml::from_str(&contents).unwrap()
}
Структура GatewayConfig
представляет собой YAML
-файл после парсинга и содержит 2 поля: authorization_api_url
и services
. Поле authorization_api_url
хранит URL
-адрес API авторизации. Поле services
— это структура данных HashMap
, сопоставляющая путь запроса со структурой ServiceConfig
. Эта структура содержит URL
-адрес целевого сервиса и порт.
Маршрутизация запросов
Задача данного этапа — направить входящие запросы к соответствующим нисходящим сервисам на основе запрошенного пути. Для обработки HTTP
-запросов и ответов применяется библиотека hyper
. Рассмотрим код для маршрутизации запросов:
use config::{load_config, GatewayConfig, ServiceConfig};
use hyper::header::HeaderValue;
use hyper::http::request::Parts;
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Client, Request, Response, Server};
use reqwest::header::{HeaderMap, AUTHORIZATION};
use std::net::SocketAddr;
mod config;
#[tokio::main]
async fn main() {
let config = load_config("config.yaml");
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
let make_svc = make_service_fn(move |_conn| {
let config = config.clone();
async {
Ok::<_, hyper::Error>(service_fn(move |req| {
let config = config.clone();
handle_request(req, config)
}))
}
});
let server = Server::bind(&addr).serve(make_svc);
if let Err(e) = server.await {
eprintln!("server error: {}", e);
}
}
async fn handle_request(
req: Request<Body>,
config: GatewayConfig,
) -> Result<Response<Body>, hyper::Error> {
let path = req.uri().path();
let service_config = match get_service_config(path.clone(), &config.services) {
Some(service_config) => service_config,
None => {
return not_found();
}
};
let auth_token = match authorize_user(&req.headers(), &config.authorization_api_url).await {
Ok(header) => header,
Err(_) => {
return service_unavailable("Failed to connect to Authorization API {}");
}
};
let (parts, body) = req.into_parts();
let downstream_req = build_downstream_request(parts, body, service_config, auth_token).await?;
match forward_request(downstream_req).await {
Ok(res) => Ok(res),
Err(_) => service_unavailable("Failed to connect to downstream service"),
}
}
fn get_service_config<'a>(path: &str, services: &'a [ServiceConfig]) -> Option<&'a ServiceConfig> {
services.iter().find(|c| path.starts_with(&c.path))
}
async fn authorize_user(headers: &HeaderMap, auth_api_url: &str) -> Result<String, ()> {
let auth_header_value = match headers.get(AUTHORIZATION) {
Some(value) => value.to_str().unwrap_or_default(),
None => "",
};
let auth_request = reqwest::Client::new()
.get(auth_api_url)
.header(AUTHORIZATION, auth_header_value);
match auth_request.send().await {
Ok(res) if res.status().is_success() => Ok(auth_header_value.to_string()),
_ => Err(()),
}
}
async fn build_downstream_request(
parts: Parts,
body: Body,
service_config: &ServiceConfig,
auth_token: String,
) -> Result<Request<Body>, hyper::Error> {
let req = Request::from_parts(parts, body);
let uri = format!(
"{}:{}{}",
service_config.target_service,
service_config.target_port,
req.uri().path()
);
let mut downstream_req_builder = Request::builder()
.uri(uri)
.method(req.method())
.version(req.version());
*downstream_req_builder.headers_mut().unwrap() = req.headers().clone();
downstream_req_builder
.headers_mut()
.unwrap()
.insert("Authorization", HeaderValue::from_str(&auth_token).unwrap());
let body_bytes = hyper::body::to_bytes(req.into_body()).await?;
let downstream_req = downstream_req_builder.body(Body::from(body_bytes));
Ok(downstream_req.unwrap())
}
async fn forward_request(req: Request<Body>) -> Result<Response<Body>, ()> {
match Client::new().request(req).await {
Ok(res) => Ok(res),
Err(_) => Err(()),
}
}
fn not_found() -> Result<Response<Body>, hyper::Error> {
let mut response = Response::new(Body::from("404 Not Found"));
*response.status_mut() = hyper::StatusCode::NOT_FOUND;
Ok(response)
}
fn service_unavailable<T>(reason: T) -> Result<Response<Body>, hyper::Error>
where
T: Into<Body>,
{
let mut response = Response::new(reason.into());
*response.status_mut() = hyper::StatusCode::SERVICE_UNAVAILABLE;
Ok(response)
}
Функция handle_request
сначала вызывает функцию authorize_user
, чтобы проверить, авторизован ли пользователь. Если он авторизован, функция ищет запрошенный путь в структуре GatewayConfig
для получения URL
-адреса целевого сервиса и порта. Если запрошенный путь не найден, функция возвращает ошибку 404 Not Found
. Если пользователь не авторизован или возникла ошибка с API авторизации, функция возвращает ответ 401 Unauthorized
.
Функция authorize_user
отправляет запрос POST
к API авторизации с заголовком Authorization
из исходного запроса. Статус ответа 200 OK
говорит о том, что пользователь авторизован, и функция возвращает Ok(())
. В противном случае пользователь не авторизован, и функция возвращает ошибку.
Функция main
загружает структуру GatewayConfig
из файла config.yaml
, создает сервер Hyper
с помощью make_service_fn
и прослушивает входящие запросы на порт 8080
.
Написание модульных тестов
На этом этапе пишем модульные тесты, чтобы убедиться в корректной работе API-шлюза. Тестируем логику авторизации, создавая 2 имитационные конечные точки API авторизации: одна из них всегда возвращает 200 OK
, а другая — 401 Unauthorized
. Отправляем запросы на API-шлюз с соответствующими заголовками Authorization
и проверяем корректность ответов.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_config() {
let path = "config.yaml";
let config = load_config(path);
assert_eq!(
config.authorization_api_url,
"https://auth.example.com/api/v1/authorization"
);
assert_eq!(config.services.len(), 2);
let service1 = &config.services[0];
assert_eq!(service1.path, "/users");
assert_eq!(service1.target_service, "http://user-service.default.svc.cluster.local");
assert_eq!(service1.target_port, "8080");
let service2 = &config.services[1];
assert_eq!(service2.path, "/orders");
assert_eq!(service2.target_service, "http://order-service.default.svc.cluster.local");
assert_eq!(service2.target_port, "8080");
}
}
#[cfg(test)]
mod tests {
use super::*;
use hyper::{StatusCode, Body};
#[tokio::test]
async fn test_handle_request_not_found() {
let req = Request::builder().uri("/unknown").body(Body::empty()).unwrap();
let config = GatewayConfig {
authorization_api_url: "http://auth.example.com".to_string(),
services: vec![ServiceConfig {
path: "/service".to_string(),
target_service: "http://service.example.com".to_string(),
target_port: "80".to_string(),
}],
};
let res = handle_request(req, config).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_handle_request_authorize_user_error() {
let req = Request::builder().uri("/service").body(Body::empty()).unwrap();
let config = GatewayConfig {
authorization_api_url: "http://auth.example.com".to_string(),
services: vec![ServiceConfig {
path: "/service".to_string(),
target_service: "http://service.example.com".to_string(),
target_port: "80".to_string(),
}],
};
let res = handle_request(req, config).await.unwrap();
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_handle_request_forward_request_error() {
let req = Request::builder().uri("/service").body(Body::empty()).unwrap();
let config = GatewayConfig {
authorization_api_url: "http://auth.example.com".to_string(),
services: vec![ServiceConfig {
path: "/service".to_string(),
target_service: "http://unknown.example.com".to_string(),
target_port: "80".to_string(),
}],
};
let res = handle_request(req, config).await.unwrap();
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
}
}
Заключение
В статье мы рассмотрели процесс реализации API-шлюза в Rust посредством библиотеки Hyper. В рамках данного проекта использовали YAML
-файл для настройки путей приложения к нисходящим сервисам и API авторизации. Кроме того, написали модульные тесты для проверки корректной работы API-шлюза.
Язык программирования Rust отлично подходит для создания высокопроизводительных сетевых приложений, а библиотека Hyper предоставляет простой и эффективный способ обработки HTTP
-запросов и ответов. Благодаря применению API-шлюза в Rust приложения становятся быстрыми, надежными и способными справляться с большими нагрузками трафика.
Принципы и методы реализации этого простого API-шлюза применимы и для создания более сложных архитектур шлюзов, которые применяются в микросервисах и нативных облачных приложениях.
Читайте также:
- Фича-флаги времени компиляции в Rust: зачем, как и когда используются
- Спецификация API — основа успешной разработки
- Rust: взгляд старого программиста
Читайте нас в Telegram, VK и Дзен
Перевод статьи Adão Raul: Building an API Gateway in Rust With Hyper