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

В данной статье мы реализуем API-шлюз в Rust посредством библиотеки Hyper. В качестве нисходящих сервисов воспользуемся сервисами Kubernetes. Кроме того, для обработки аутентификации пользователя реализуем сторонний API авторизации. 

Создание среды разработки 

Прежде чем приступить к написанию кода, необходимо создать среду разработки. Воспользуемся Rust в качестве программного языка и библиотекой Hyper для обработки HTTP-запросов. Кроме того, потребуются библиотеки serde и yaml-rust для работы с YAML-файлами. Перечислим этапы создания среды.

  1. Установка Rust и Cargo по инструкциям с официального сайта Rust.
  2. Создание нового проекта Rust с использованием cargo new <project-name>.
  3. Добавление необходимых зависимостей в файл Cargo.toml, как показано ниже. 
  4. Выполнение команды 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-шлюза применимы и для создания более сложных архитектур шлюзов, которые применяются в микросервисах и нативных облачных приложениях. 

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Adão Raul: Building an API Gateway in Rust With Hyper

Предыдущая статья4 способа улучшить навыки написания кода
Следующая статьяЧто такое Next.js App Router и готов ли он к использованию в производстве