REST API в Rust с помощью warp

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

Что такое Warp?

Warp — это минимальный и эффективный веб-фреймворк для создания веб-сервисов на основе HTTP в Rust. Он предоставляет API высокого уровня для создания HTTP-серверов с упором на безопасность, производительность и стабильность. Warp также включает в себя встроенные функции, такие как поддержка HTTP/1 и HTTP/2, шифрование TLS, асинхронное программирование и общее промежуточное программное обеспечение для задач регистрации, ограничения скорости и маршрутизации. Многие его функции позаимствованы у Hyper , поскольку warp больше похож на надмножество Hyper.

Настройка вашего проекта

Чтобы следовать этому руководству, вам потребуется установить следующие библиотеки.

  • Warp для создания API
  • Tokio запустит асинхронный сервер
  • Serde поможет сериализовать входящий JSON
  • park_lot для создания ReadWriteLock локального хранилища

Сначала создадим новый проект с грузом:

cargo new neat-api --bin

Мы включили Cargo.toml в нашу программу warp , чтобы можно было использовать ее во всей нашей кодовой базе:

[dependencies]
warp = "0.2"
parking_lot = "0.10.0"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "0.2", features = ["macros"] }

Для первого теста создайте простой запрос «Hello, World!» в main.rs, как показано ниже:

use warp::Filter;

#[tokio::main]
async fn main() {
    // GET /hello/warp => 200 OK with body "Hello, warp!"
    let hello = warp::path!("hello" / String)
        .map(|name| format!("Hello, {}!", name));

    warp::serve(hello)
        .run(([127, 0, 0, 1], 3030))
        .await;
}

Filters — это способ проанализировать запрос и сопоставить его с созданным нами маршрутом. Поэтому, когда вы запускаете сервер через cargo run и указываете в своем браузере на localhost:3030/hello/WHATEVER, Warp отправляет этот запрос через свои фильтры и выполняет первый из них, который срабатывает.

В let hello = … мы создали новый путь, по сути говоря, что каждый запрос с path /hello плюс строка обрабатывается этим методом. Итак, мы отвечаем Hello, WHATEVER. Если мы укажем браузеру на localhost:3030/hello/new/WHATEVER, мы получим 404, поскольку у нас нет фильтра для /hello/new + String.

Создание REST API

Давайте создадим реальный API для демонстрации этих концепций. Хорошая модель — API для списка покупок. Мы хотим иметь возможность добавлять элементы в список, обновлять количество, удалять элементы и просматривать весь список. Поэтому нам нужны четыре разных маршрута с методами HTTP GET , DELETE, PUT и POST.

При таком большом количестве различных маршрутов разумно ли создавать методы для каждого вместо того, чтобы обрабатывать их все в main.rs?

Создание локального хранилища

Помимо маршрутов нам необходимо хранить состояние в файле или локальной переменной. В асинхронной среде мы должны быть уверены, что только один метод может получить доступ к хранилищу одновременно, чтобы не было несоответствий между потоками.

В Rust у нас есть Arc, поэтому компилятор знает, когда удалить значение и блокировку чтения и записи ( RwLock). Таким образом, никакие два метода в разных потоках не будут выполнять запись в одну и ту же память.

Реализация вашего магазина должна выглядеть так:

use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;

type Items = HashMap<String, i32>;

#[derive(Debug, Deserialize, Serialize, Clone)]
struct Item {
    name: String,
    quantity: i32,
}

#[derive(Clone)]
struct Store {
  grocery_list: Arc<RwLock<Items>>
}

impl Store {
    fn new() -> Self {
        Store {
            grocery_list: Arc::new(RwLock::new(HashMap::new())),
        }
    }
}

POST добавление элемента в список

Теперь мы можем добавить наш первый маршрут. Чтобы добавить элементы в список, сделайте HTTP- POST запрос к пути. Наш метод должен возвращать правильный HTTP-код, чтобы вызывающий абонент знал, был ли его вызов успешным. Warp предлагает базовые типы через собственную http библиотеку, которую нам тоже необходимо включить.

Добавьте это так:

use warp::{http, Filter};

Метод запроса POST выглядит следующим образом:

async fn add_grocery_list_item(
    item: Item,
    store: Store
    ) -> Result<impl warp::Reply, warp::Rejection> {
        let r = store.grocery_list.read();
        Ok(warp::reply::json(&*r))
}

Фреймворк warp предлагает возможность отвечать со статусом, чтобы мы могли добавить текст плюс общий статус HTTP, чтобы вызывающий абонент знал, был ли запрос успешным или ему нужно повторить попытку.

Теперь добавьте новый маршрут и вызовите метод, который вы только что создали для него. Поскольку вы можете ожидать для этого JSON, вам следует создать небольшую вспомогательную функцию json_body, чтобы извлечь элемент из тела HTTP-запроса.

Кроме того, нам нужно передать хранилище каждому методу, клонировав его и создав фильтр деформации, который мы вызываем в .and() во время создания пути деформации:

fn json_body() -> impl Filter<Extract = (Item,), Error = warp::Rejection> + Clone {
    // When accepting a body, we want a JSON body
    // (and to reject huge payloads)...
    warp::body::content_length_limit(1024 * 16).and(warp::body::json())
}

#[tokio::main]
async fn main() {
    let store = Store::new();
    let store_filter = warp::any().map(move || store.clone());

    let add_items = warp::post()
        .and(warp::path("v1"))
        .and(warp::path("groceries"))
        .and(warp::path::end())
        .and(json_body())
        .and(store_filter.clone())
        .and_then(add_grocery_list_item);

    warp::serve(add_items)
        .run(([127, 0, 0, 1], 3030))
        .await;
}

Вы можете протестировать POST вызов через curl такое приложение, как Postman , которое теперь является автономным приложением для выполнения HTTP-запросов. Запустите сервер cargo run и откройте другое окно или вкладку терминала, чтобы выполнить следующее curl:

curl --location --request POST 'localhost:3030/v1/groceries' \
--header 'Content-Type: application/json' \
--header 'Content-Type: text/plain' \
--data-raw '{
        "name": "apple",
        "quantity": 3
}'

Вы должны получить текстовый ответ и HTTP-код, как определено в вашем методе.

GET получение списка продуктов

Теперь мы можем опубликовать список товаров в нашем списке покупок, но по-прежнему не можем их получить. Нам нужно создать другой маршрут для GET запроса. Наша основная функция добавит этот новый маршрут. Для этого нового маршрута нам не нужно анализировать JSON. Вот код:

#[tokio::main]
async fn main() {
    let store = Store::new();
    let store_filter = warp::any().map(move || store.clone());

    let add_items = warp::post()
        .and(warp::path("v1"))
        .and(warp::path("groceries"))
        .and(warp::path::end())
        .and(json_body())
        .and(store_filter.clone())
        .and_then(add_grocery_list_item);

    let get_items = warp::get()
        .and(warp::path("v1"))
        .and(warp::path("groceries"))
        .and(warp::path::end())
        .and(store_filter.clone())
        .and_then(get_grocery_list);


    let routes = add_items.or(get_items);

    warp::serve(routes)
        .run(([127, 0, 0, 1], 3030))
        .await;
}

Вы почувствуете асинхронность Rust, когда изучите структуру данных вашего Arc. Вам нужно будет использовать метод .read() для доступа к данным и их разыменования. Вот как выглядит функция:

async fn get_grocery_list(
    store: Store
    ) -> Result<impl warp::Reply, warp::Rejection> {
         let result = store.grocery_list.read();
        Ok(warp::reply::json(&*result))
}

Затем создайте переменную для store.grocery_list.read(), мы назовем ее result. Обратите внимание, что мы возвращаем &*result; это что-то новенькое, не так ли? Да, &*result разыменовывает результат объекта RwLockReadGuard на &HashMap, который затем передается как ссылка на возвращенную функцию warp::reply::json.

UPDATE иDELETE

Последние два отсутствующих метода - это UPDATE и DELETE. Для DELETE вы можете почти скопировать свой add_grocery_list_item, но вместо .insert(), .remove() запись. Особым случаем является UPDATE.

Здесь реализация Rust HashMap также использует .insert(), но она обновляет значение вместо создания новой записи, если ключ не существует. Поэтому просто переименуйте метод и вызовите его как для POST, так и для PUT.

Для метода DELETE вам нужно передать только имя элемента, поэтому создайте новую структуру и добавьте другой метод parse_json() для нового типа. Переименуйте первый метод синтаксического анализа и добавьте другой. Вы можете просто переименовать свой метод add_grocery_list_item, чтобы вызвать его update_grocery_list и вызвать его для warp::post() и warp::put(). Ваш полный код должен выглядеть следующим образом:

use warp::{http, Filter};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Serialize, Deserialize};

type Items = HashMap<String, i32>;

#[derive(Debug, Deserialize, Serialize, Clone)]
struct Id {
    name: String,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
struct Item {
    name: String,
    quantity: i32,
}

#[derive(Clone)]
struct Store {
  grocery_list: Arc<RwLock<Items>>
}

impl Store {
    fn new() -> Self {
        Store {
            grocery_list: Arc::new(RwLock::new(HashMap::new())),
        }
    }
}

async fn update_grocery_list(
    item: Item,
    store: Store
    ) -> Result<impl warp::Reply, warp::Rejection> {
        store.grocery_list.write().insert(item.name, item.quantity);


        Ok(warp::reply::with_status(
            "Added items to the grocery list",
            http::StatusCode::CREATED,
        ))
}

async fn delete_grocery_list_item(
    id: Id,
    store: Store
    ) -> Result<impl warp::Reply, warp::Rejection> {
        store.grocery_list.write().remove(&id.name);


        Ok(warp::reply::with_status(
            "Removed item from grocery list",
            http::StatusCode::OK,
        ))
}

async fn get_grocery_list(
    store: Store
    ) -> Result<impl warp::Reply, warp::Rejection> {
        let r = store.grocery_list.read();
        Ok(warp::reply::json(&*r))
}

fn delete_json() -> impl Filter<Extract = (Id,), Error = warp::Rejection> + Clone {
    // When accepting a body, we want a JSON body
    // (and to reject huge payloads)...
    warp::body::content_length_limit(1024 * 16).and(warp::body::json())
}

fn post_json() -> impl Filter<Extract = (Item,), Error = warp::Rejection> + Clone {
    // When accepting a body, we want a JSON body
    // (and to reject huge payloads)...
    warp::body::content_length_limit(1024 * 16).and(warp::body::json())
}

#[tokio::main]
async fn main() {
    let store = Store::new();
    let store_filter = warp::any().map(move || store.clone());

    let add_items = warp::post()
        .and(warp::path("v1"))
        .and(warp::path("groceries"))
        .and(warp::path::end())
        .and(post_json())
        .and(store_filter.clone())
        .and_then(update_grocery_list);

    let get_items = warp::get()
        .and(warp::path("v1"))
        .and(warp::path("groceries"))
        .and(warp::path::end())
        .and(store_filter.clone())
        .and_then(get_grocery_list);

    let delete_item = warp::delete()
        .and(warp::path("v1"))
        .and(warp::path("groceries"))
        .and(warp::path::end())
        .and(delete_json())
        .and(store_filter.clone())
        .and_then(delete_grocery_list_item);


    let update_item = warp::put()
        .and(warp::path("v1"))
        .and(warp::path("groceries"))
        .and(warp::path::end())
        .and(post_json())
        .and(store_filter.clone())
        .and_then(update_grocery_list);



    let routes = add_items.or(get_items).or(delete_item).or(update_item);

    warp::serve(routes)
        .run(([127, 0, 0, 1], 3030))
        .await;
}

Понимание тестирования curls

После обновления кода перезапустите сервер cargo run и используйте эти завитки для публикации, обновления, получения и удаления элементов.

POST

curl --location --request POST 'localhost:3030/v1/groceries' \
--header 'Content-Type: application/json' \
--header 'Content-Type: text/plain' \
--data-raw '{
        "name": "apple",
        "quantity": 3
}'

UPDATE

curl --location --request PUT 'localhost:3030/v1/groceries' \
--header 'Content-Type: application/json' \
--header 'Content-Type: text/plain' \
--data-raw '{
        "name": "apple",
        "quantity": 5
}'

GET

curl --location --request GET 'localhost:3030/v1/groceries' \
--header 'Content-Type: application/json' \
--header 'Content-Type: text/plain'

DELETE

curl --location --request DELETE 'localhost:3030/v1/groceries' \
--header 'Content-Type: application/json' \
--header 'Content-Type: text/plain' \
--data-raw '{
        "name": "apple"
}'

Подводя итог шагам, которые мы только что рассмотрели:

  • Создайте идентификатор для каждого элемента, чтобы вы могли обновлять и удалять его через/v1/groceries/{id}
  • Добавить маршрут 404
  • Добавьте обработку ошибок для некорректного формата JSON.
  • Настройте обратные сообщения для каждого маршрута
  • Добавить тест для каждого маршрута с завитками

Зачем использовать Warp в Rust

Когда дело доходит до создания API в Rust, у вас есть несколько вариантов библиотеки. Тем не менее, конкретные требования вашего проекта помогут определить ваш выбор. Если вы решите использовать warp, вот некоторые преимущества его использования в вашем проекте Rust.

  1. Производительность: Warp спроектирован так, чтобы быть быстрым и эффективным, с упором на асинхронную обработку и функции оптимизации производительности, такие как автоматическое поддержание активности HTTP и пул соединений.
  2. Безопасность: Warp уделяет большое внимание безопасности благодаря таким функциям, как встроенная поддержка шифрования TLS, обеспечивающая безопасную передачу ваших данных по сети.
  3. Простота: warp предоставляет высокоуровневый API, который прост в использовании, но при этом мощный и настраиваемый. Это упрощает начало создания HTTP-серверов и позволяет легко расширять приложение дополнительными функциями по мере необходимости.
  4. Надежность: Warp спроектирован так, чтобы быть стабильным и надежным, с упором на обработку ошибок и отчетность.
  5. Масштабируемость: Warp спроектирован так, чтобы быть масштабируемым, с поддержкой HTTP/1 и HTTP/2 и эффективным использованием ресурсов, что делает его отличным выбором для создания высокопроизводительных и масштабируемых веб-приложений.

Последние мысли

Warp — интересный инструмент для создания веб-API с помощью Rust. И хотя код далек от совершенства, пример кода дает нам верхушку айсберга того, что возможно с помощью warp.