Тест на вакансию

Создание REST API приложения на Rust с авторизацией по токену

6 августа 2025 г.
91

Установка Rust

Начнём с установки Rust, загружаем его отсюда https://www.rust-lang.org/tools/install или выволняем команду:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

После установки проверяем, что Rust и Cargo (менеджер пакетов Rust) установлены:
rustc --version
cargo --version
Создаём новый проект:
cargo new rust_api_app
cd rust_api_app

Настройка проекта

Добавим зависимости в Cargo.toml:
[package]
name = "rust_api_app"
version = "0.1.0"
edition = "2024"

[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
jsonwebtoken = "9.0"
chrono = { version = "0.4", features = ["serde"] }
bcrypt = "0.15"
dotenv = "0.15"
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-native-tls"] }
futures = "0.3"
rand = "0.9"
Где:
  • actix-web - веб-фреймворк
  • serde - сериализация/десериализация
  • jsonwebtoken - работа с JWT
  • chrono - работа с временем
  • bcrypt - хеширование паролей
  • dotenv - загрузка переменных окружения

Генерируем хеш для пароля "password" и секретный ключ JWT

Создадим временный скрипт для генерации хеша (/src/bin/generate_hash_and_jwt_secret.rs):
use bcrypt::{hash, DEFAULT_COST};
use rand::{RngCore, rng};

fn generate_jwt_secret(length: usize) -> String {
    let mut key = vec![0u8; length];
    let mut rng = rng();
    rng.fill_bytes(&mut key);
    key.iter()
        .map(|byte| format!("{:02x}", byte))
        .collect::<String>()
}
fn main() {
    let password = "password";
    match hash(password, DEFAULT_COST) {
        Ok(hashed) => println!("USER_PASSWORD_HASH '{}': {}", password, hashed),
        Err(e) => eprintln!("Ошибка генерации хеша для пароля '{}': {:?}", password, e),
    }
    let secret_length = 32;
    let jwt_secret = generate_jwt_secret(secret_length);
    println!("JWT_SECRET: {}", jwt_secret);
}
Запустим его:
cargo run --bin generate_hash_and_jwt_secret
Получим что-то вроде:
USER_PASSWORD_HASH 'password': $2b$12$uFHCVOBJVui2q/xzRQkbZuVQhDW8r4Quj13FRN0tvC1CKj1bKVaky
JWT_SECRET: 0f9706f13302bf2b14259775fad5005e301fc251fde34c19022275a1d69b92b0
Создадим файл .env и запишем туда сгенерированный хэш пароля и секретный ключ JWT:
DATABASE_URL=postgres://<your_username>:<your_password>@localhost/rust_api_app
JWT_SECRET=<your_jwt_secret>
JWT_EXP=3600
USER_NAME=admin
USER_PASSWORD_HASH='<your_user_password_hash>'
Приложение generate_hash_and_jwt_secret можно удалять.

Настройка базы данных

Создадим БД rust_api_app:

psql -U postgres
CREATE DATABASE rust_api_app;
Установим sqlx-cli:
cargo install sqlx-cli
Создадим миграцию:
sqlx migrate add create_articles_table
Отредактируем файл миграции (migrations/xxxxxxxxxxxxxx_create_articles_table.php):
CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    description TEXT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
Выполним миграции:
sqlx migrate run

Создание API

Отредактируем файл (main.rs):
mod models;
mod auth;
mod articles;

use actix_web::{web, App, HttpServer};
use dotenv::dotenv;
use std::env;
use sqlx::postgres::PgPoolOptions;
use crate::articles::{create_article, delete_article, get_article, get_articles, update_article};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
   
    let jwt_secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
    let jwt_exp = env::var("JWT_EXP").unwrap_or("3600".to_string()).parse().unwrap();
    let db_url = env::var("DATABASE_URL").expect("DATABASE URL must be set");
 
    let app_state = models::AppState {
        jwt_secret,
        jwt_exp,
    };
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&db_url)
        .await
        .unwrap();
    HttpServer::new(move || {
    App::new()
        .app_data(web::Data::new(app_state.clone()))
        .app_data(web::Data::new(pool.clone()))
        .service(
            web::scope("/api")
                .service(get_articles)
                .service(get_article)
                .service(
                    web::resource("/login")
                        .route(web::post().to(auth::login))
                )
                .service(
                    web::scope("")
                        .wrap(auth::JwtMiddleware)
                        .service(create_article)
                        .service(update_article)
                        .service(delete_article)
                )
        )
    })
    .bind("127.0.0.1:8888")?
    .run()
    .await
}
Создадим и отредактируем файл моделей (models.rs):
use serde::{Deserialize, Serialize};
use jsonwebtoken::{EncodingKey, DecodingKey};

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
    pub sub: String,
    pub exp: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
    pub username: String,
    pub password: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}
#[derive(Clone)]
pub struct AppState {
    pub jwt_secret: String,
    pub jwt_exp: i64,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct Article {
    id: i32,
    title: String,
    description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateArticleRequest {
    pub(crate) title: String,
    pub(crate) description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateArticleRequest {
    pub(crate) title: String,
    pub(crate) description: Option<String>,
}
impl AppState {
    pub fn encoding_key(&self) -> EncodingKey {
        EncodingKey::from_secret(self.jwt_secret.as_bytes())
    }
   
    pub fn decoding_key(&self) -> DecodingKey {
        DecodingKey::from_secret(self.jwt_secret.as_bytes())
    }
}
Создадим и отредактивруем файл аутентификации (auth.rs):
use actix_web::{
    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
    Error, HttpResponse, web
};
use futures::future::LocalBoxFuture;
use jsonwebtoken::{decode, Validation};
use std::future::{ready, Ready};
use std::env;
use crate::models::{Claims, AppState};

pub struct JwtMiddleware;
impl<S, B> Transform<S, ServiceRequest> for JwtMiddleware
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Transform = JwtMiddlewareService<S>;
    type InitError = ();
    type Future = Ready<Result<Self::Transform, Self::InitError>>;
    fn new_transform(&self, service: S) -> Self::Future {
        ready(Ok(JwtMiddlewareService { service }))
    }
}
pub struct JwtMiddlewareService<S> {
    service: S,
}
impl<S, B> Service<ServiceRequest> for JwtMiddlewareService<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
    forward_ready!(service);
    fn call(&self, req: ServiceRequest) -> Self::Future {
        let app_state = req.app_data::<web::Data<AppState>>().unwrap();
        let auth_header = req.headers().get("Authorization");
       
        if auth_header.is_none() {
            return Box::pin(async {
                Err(actix_web::error::ErrorUnauthorized("Missing Authorization header"))
            });
        }
        let auth_str = match auth_header.unwrap().to_str() {
            Ok(s) => s,
            Err(_) => return Box::pin(async {
                Err(actix_web::error::ErrorUnauthorized("Invalid Authorization header"))
            }),
        };
        if !auth_str.starts_with("Bearer ") {
            return Box::pin(async {
                Err(actix_web::error::ErrorUnauthorized("Invalid token format"))
            });
        }
        let token = &auth_str[7..];
        let validation = Validation::default();
        let secret = &app_state.decoding_key();
        match decode::<Claims>(token, secret, &validation) {
            Ok(_token_data) => {
                let fut = self.service.call(req);
                Box::pin(async move {
                    let res = fut.await?;
                    Ok(res)
                })
            }
            Err(_) => Box::pin(async {
                Err(actix_web::error::ErrorUnauthorized("Invalid token"))
            }),
        }
    }
}
pub async fn login(
    data: web::Data<AppState>,
    credentials: web::Json<crate::models::LoginRequest>,
) -> Result<HttpResponse, Error> {
    use bcrypt::verify;
   
    let user_name = env::var("USER_NAME").expect("USER NAME must be set");
    let user_password_hash = env::var("USER_PASSWORD_HASH").expect("USER PASSWORD HASH must be set");
       
    if credentials.username != user_name {
        return Err(actix_web::error::ErrorUnauthorized("Invalid username"));
    }
   
    if !verify(&credentials.password, &user_password_hash).unwrap_or(false) {
        return Err(actix_web::error::ErrorUnauthorized("Invalid password"));
    }
   
    let expiration = chrono::Utc::now()
        .checked_add_signed(chrono::Duration::seconds(data.jwt_exp))
        .expect("Invalid timestamp")
        .timestamp() as usize;
   
    let claims = Claims {
        sub: credentials.username.clone(),
        exp: expiration,
    };
   
    let token = jsonwebtoken::encode(
        &jsonwebtoken::Header::default(),
        &claims,
        &data.encoding_key(),
    ).unwrap();
   
    Ok(HttpResponse::Ok().json(token))
}
Создадим и отредактивруем файл работы со статьями (articles.rs):
use actix_web::{get, post, put, delete, web, Responder, HttpResponse};
use serde_json::json;
use crate::models::{Article, CreateArticleRequest, UpdateArticleRequest};

#[get("/articles")]
async fn get_articles(pool: web::Data<sqlx::PgPool>) -> impl Responder {
    let articles: Vec<Article> = sqlx::query_as::<_, Article>("SELECT id, title, description FROM articles")
        .fetch_all(&**pool)
        .await
        .unwrap();
    web::Json(articles)
}
#[get("/articles/{id}")]
async fn get_article(pool: web::Data<sqlx::PgPool>, id: web::Path<i32>) -> HttpResponse {
    match sqlx::query_as::<_, Article>("SELECT id, title, description FROM articles WHERE id = $1")
        .bind(id.into_inner())
        .fetch_optional(&**pool)
        .await
    {
        Ok(Some(article)) => HttpResponse::Ok().json(article),
        Ok(None) => HttpResponse::NotFound().json(json!({
            "error": "Article not found"
        })),
        Err(e) => {
            eprintln!("DB error: {}", e);
            HttpResponse::InternalServerError().finish()
        }
    }
}
#[post("/articles")]
async fn create_article(
    pool: web::Data<sqlx::PgPool>,
    article_data: web::Json<CreateArticleRequest>,
) -> HttpResponse {
    match sqlx::query_as::<_, Article>(
        "INSERT INTO articles (title, description)
        VALUES ($1, $2)
        RETURNING id, title, description",
    )
    .bind(&article_data.title)
    .bind(&article_data.description)
    .fetch_one(&**pool)
    .await
    {
        Ok(article) => HttpResponse::Created().json(article),
        Err(e) => {
            eprintln!("DB error: {}", e);
            HttpResponse::InternalServerError().finish()
        }
    }
}
#[put("/articles/{id}")]
async fn update_article(
    pool: web::Data<sqlx::PgPool>,
    id: web::Path<i32>,
    article_data: web::Json<UpdateArticleRequest>,
) -> HttpResponse {
    match sqlx::query_as::<_, Article>(
        "UPDATE articles
        SET
            title = COALESCE($1, title),
            description = COALESCE($2, description)
        WHERE id = $3
        RETURNING id, title, description",
    )
    .bind(&article_data.title)
    .bind(&article_data.description)
    .bind(id.into_inner())
    .fetch_optional(&**pool)
    .await
    {
        Ok(Some(article)) => HttpResponse::Ok().json(article),
        Ok(None) => HttpResponse::NotFound().json(json!({
            "error": "Article not found"
        })),
        Err(e) => {
            eprintln!("DB error: {}", e);
            HttpResponse::InternalServerError().finish()
        }
    }
}
#[delete("/articles/{id}")]
async fn delete_article(pool: web::Data<sqlx::PgPool>, id: web::Path<i32>) -> HttpResponse {
    match sqlx::query("DELETE FROM articles WHERE id = $1")
        .bind(id.into_inner())
        .execute(&**pool)
        .await
    {
        Ok(result) => {
            if result.rows_affected() > 0 {
                HttpResponse::NoContent().finish()
            } else {
                HttpResponse::NotFound().json(json!({
                    "error": "Article not found"
                }))
            }
        }
        Err(e) => {
            eprintln!("DB error: {}", e);
            HttpResponse::InternalServerError().finish()
        }
    }
}

Тестирование API

Запускаем наш API:
cargo run
Сервер запуститься по адресу http://127.0.0.1:8888.

Для тестирования API можно использовать:
  • Postman
  • curl
Получение токена (POST /api/login):
curl -X POST \
  http://localhost:8888/api/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"password"}'
Получение всех статей:
curl http://localhost:8888/api/articles
Получение статьи (с id = 1):
curl http://localhost:8888/api/articles/1
Создание статьи (POST):
curl -X POST http://localhost:8888/api/articles \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  -d '{
    "title": "Статья №1",
    "description": "Описание статьи №1"
  }'
Обновление статьи (PUT):
curl -X PUT http://localhost:8888/api/articles/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  -d '{
    "title": "Статья №1 (обновлённая)"
  }'
Удаление статьи (DELETE):
curl -X DELETE http://localhost:8888/api/articles/1 \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"
 
Наш API для работы со статьями на Rust готов и протестирован!
Поделиться: