6 августа 2025 г.
Установка 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 можно использовать:
Получение токена (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 готов и протестирован!