2021/04/10

概略

今日は午前にGCJ, 午後は買った収納グッズが届いたのでオタクグッズの押し込み作業をした. 引っ越してからずっとダンボールに入れていたグッズたちもやっとまともな収納グッズに入れることが出来たし, 収納グッズの天板部分にアクリルフィギュアとかも飾れるようになった. これはQoLが上がる. その分部屋は狭くなったが.

actix-webを触ってるだけの話.

最近Androidの内部実装にも使われだしたとかでちょっと話題になったRust.

そのRustのweb-frameworkにactix-webがあってこいつの性能がすこぶる良いらしいと聞いて触ってみているというだけの話. 今日はログインAPIを書くトコまで.

Actix Web | A powerful, pragmatic, and extremely fast web framework for Rust.

使ってるパッケージはこんな感じ, 多分要らないのも混じっている.

[dependencies]
actix-web = "3.3.0"
actix-files = "0.5.0"
actix-http = "2.2.0"
actix-multipart = "0.3.0"
actix-web-actors = "3.0.0"
actix-web-codegen = "0.4.0"
actix-cors = "0.5.3"

actix-rt = "1.1.1"
actix-utils = "2.0.0"
actix-redis = "0.9.1"
actix-session = "0.4.0"
actix-identity = "0.3.1"

# for front
handlebars = { version = "3.5.0", features = ["dir_source"] }
serde = {version = "1.0", features = ["derive"]}
serde_json = "1.0"
serde_yaml = "0.8"

# for DB
diesel = { version = "1.4.5", features = ["postgres",  "r2d2", "chrono"] }
dotenv = "0.15.0"
chrono = { version = "0.4.6", features = ["serde"] }
bcrypt = "0.8"

# for logging
log = "0.4.11"
env_logger = "0.8.1"
derive_more = "0.99.11"

ORMとしてはdieselが有名.

Diesel

featuresにchronoを加えておくことで, timestampの扱いが楽になる. パスワードの暗号化とかはbcryptで.

dieselの設定でschemaの出力先を変えておくと後々が楽になる. (diesel.toml)

# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli

[print_schema]
file = "src/repositories/schema.rs"

ついでにコマンド時にmigrationを置くディレクトリを指定出来る.

これについてはtomlに入れれば効くようにするPRがmergeされてるので, 近々tomlに入れられるかも => Configure the migration directory in diesel.toml by theredfish · Pull Request #2179 · diesel-rs/diesel · GitHub)

$ diesel setup --migration-dir db/migrations
$ diesel migration generate [migration-name] --migration-dir db/migrations

後は適当にSQLを書き書きしてmigration

CREATE TABLE user_roles (
  id INTEGER NOT NULL UNIQUE,
  role_name VARCHAR(20) NOT NULL PRIMARY KEY
);

-- create index
CREATE INDEX idx_user_roles_id ON user_roles ( id );

CREATE TABLE users (
  id SERIAL NOT NULL PRIMARY KEY,
  name VARCHAR NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  password VARCHAR NOT NULL,
  role VARCHAR(20) NOT NULL
);

-- create index
CREATE INDEX idx_users_email ON users ( email );
CREATE INDEX idx_users_role ON users ( role );

-- add foreing key
ALTER TABLE users 
  ADD CONSTRAINT fk_users_role
  FOREIGN KEY ( role )
    REFERENCES user_roles ( role_name )
    ON DELETE CASCADE 
    ON UPDATE CASCADE;
table! {
    user_roles (role_name) {
        id -> Int4,
        role_name -> Varchar,
    }
}

table! {
    users (id) {
        id -> Int4,
        name -> Varchar,
        email -> Varchar,
        password -> Varchar,
        role -> Varchar,
    }
}

joinable!(users -> user_roles (role));

allow_tables_to_appear_in_same_query!(
  user_roles, users,
);

DB接続プールを取得する関数を作って

//use diesel::prelude::*;
use diesel::pg::PgConnection;
use diesel::r2d2::{self, ConnectionManager};
use dotenv::dotenv;
use std::env;

pub type DbPool =
  r2d2::Pool<ConnectionManager<PgConnection>>;

pub fn build_db_pool() -> DbPool {
  dotenv().ok();

  let database_url = env::var("DATABASE_URL")
    .expect("DATABASE_URL must be set");
  let manager =
    ConnectionManager::<PgConnection>::new(database_url);
  r2d2::Pool::builder()
    .build(manager)
    .expect("Failed to create pool.")
}

行データに対応するモデルを作っておいて

use crate::models::user_roles::UserRole;
use crate::repositories::schema::users;
use diesel::Identifiable;

#[derive(
  Debug,
  Clone,
  Serialize,
  Deserialize,
  Eq,
  PartialEq,
  Identifiable,
  Associations,
  Queryable,
  AsChangeset,
)]
#[belongs_to(UserRole, foreign_key = "role")]
#[table_name = "users"]
#[primary_key("id")]
pub struct User {
  pub id: i32,
  pub name: String,
  pub email: String,
  pub password: String,
  pub role: String,
}

#[derive(
  Debug,
  Clone,
  Serialize,
  Deserialize,
  PartialEq,
  Insertable,
)]
#[table_name = "users"]
pub struct NewUser {
  pub name: String,
  pub email: String,
  pub password: String,
  pub role: String,
}


#[derive(Debug, Serialize, Deserialize)]
pub struct CreateSession {
  pub email: String,
  pub password: String,
}

RESTっぽいUtilityを実装しておいて

use crate::models::users::{NewUser, User};
use crate::repositories::schema::users::dsl::*;
use diesel::prelude::*;


impl User {
  pub fn find(
    q_id: &i32,
    conn: &PgConnection,
  ) -> Result<Option<User>, diesel::result::Error> {
    let user = users
      .filter(id.eq(&q_id))
      .first::<User>(conn)
      .optional()?;

    Ok(user)
  }

  pub fn find_by_email(
    q_email: &str,
    conn: &PgConnection,
  ) -> Result<Option<User>, diesel::result::Error> {
    let user = users
      .filter(email.eq(&q_email))
      .first::<User>(conn)
      .optional()?;

    return Ok(user);
  }

  pub fn create(
    new_user: &NewUser,
    conn: &PgConnection,
  ) -> Result<User, diesel::result::Error> {
    diesel::insert_into(users)
      .values(new_user)
      .get_result(conn)
  }
}

パスワード照合処理を書いて

use bcrypt::{hash, verify, BcryptError, DEFAULT_COST};

static COST: u32 = DEFAULT_COST;

pub fn hash_password(
  password: &str,
) -> Result<String, BcryptError> {
  hash(password, COST)
}

pub fn verify_password(
  password: &str,
  hash: &str,
) -> Result<bool, BcryptError> {
  verify(password, hash)
}

ログイン / ログアウト処理を書いてみる.

use actix_identity::Identity;
use actix_web::{web, HttpResponse, Result};

use crate::models::users::CreateSession;
use crate::models::users::User;
use crate::repositories::connection::DbPool;
use crate::services::crypt::verify_password;
use crate::services::errors::StatusError; // 自分で適当に書くカスタムエラー
// ** NOT SUPPORT SIGN UP **

pub async fn signin(
  info: web::Json<CreateSession>,
  id: Identity,
  pool: web::Data<DbPool>,
) -> Result<HttpResponse, StatusError> {
  let conn = pool.get()?;
  if let Some(user) =
    User::find_by_email(&info.email, &conn)?
  {
    if verify_password(&info.password, &user.password)? {
      id.remember(user.id.to_string());
      return Ok(HttpResponse::Ok().finish());
    }
  }
  Err(StatusError::Unauthorized)
}

pub async fn signout(id: Identity) -> Result<HttpResponse> {
  id.forget();
  Ok(HttpResponse::NoContent().finish())
}

これでroutingを書いて

use actix_web::{web};
use crate::controllers;

// 使えるのはservice, route, scope, resource等だけ, default_serviceとかは使えないので注意
pub fn route_configure(cfg: &mut web::ServiceConfig) {
  cfg.service(
    web::scope("/auths")
      .service(
        web::resource("/signin").route(
          web::post().to(controllers::apis::auths::signin),
        )
      )
      .service(
        web::resource("/signout").route(
          web::delete().to(controllers::apis::auths::signout),
        )
      ),
  );
}

identityの設定を書いて

use actix_identity::{
  CookieIdentityPolicy, IdentityService,
};
use actix_http::cookie::SameSite;

pub fn identity_configure() -> IdentityService<CookieIdentityPolicy>{
  IdentityService::new(
    CookieIdentityPolicy::new(&[0; 32]) // Cookieに認証情報を持つ
      .name("auths")  // Cookieの名前
      .path("/") // Cookieを送信対称とするパス
      .domain("localhost:8088") // Cookieを送信対称とするドメイン
      .same_site(SameSite::Strict) // Cookieを送信するポリシー設定
      .max_age(3600) // 有効期限
      .secure(true), // Secureをつけるかどうか
  )
}

mainを書く

#[macro_use]
extern crate serde;
#[macro_use]
extern crate serde_json;
extern crate serde_yaml;
#[macro_use]
extern crate diesel;

use actix_web::{web, App, HttpServer};

use std::env;
use std::io::{Error, ErrorKind};
use env_logger as logger;

mod models;
mod repositories;
mod controllers;
mod services;
mod configs;

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
  // logger setting
  std::env::set_var(
    "RUST_LOG",
    "actix_web=info,diesel=debug",
  );
  logger::init();
  
  let db_pool = repositories::connection::build_db_pool();

  // start server
  HttpServer::new(move || {
    let identity = configs::identity::identity_configure();

    App::new()
      .wrap(identity)
      .data(db_pool.clone())
      .configure(configs::routes::route_configure)
      .default_service(
        web::route().to(controllers::not_found),
      )
  })
  .bind("127.0.0.1:8088")?
  .run()
  .await
}

これでセッション情報をCookieに保持するログインAPIの出来上がり(?)

> curl -X POST -v http://localhost:8088/auths/signin -d '{"email":"kilattoeruru@gmail.com","password":"hogefugafoobar"}' -H "Content-Type: application/json"
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8088 (#0)
> POST /auths/signin HTTP/1.1
> Host: localhost:8088
> User-Agent: curl/7.58.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 61
> 
* upload completely sent off: 61 out of 61 bytes
< HTTP/1.1 200 OK
< content-length: 0
< set-cookie: auths=xxxxx; HttpOnly; SameSite=Strict; Secure; Path=/; Domain=localhost:8088; Max-Age=3600
< date: Sat, 10 Apr 2021 14:11:39 GMT
< 
* Connection #0 to host localhost left intact

今日はここまで

GCJ

Round 1 Aは敗退, 2445位. 1問目完答, 2問目部分点まで.

ボーダー的には1問目完答, 2問目部分点, 3問目部分点が速く出せれば行けてたようだ. 残念.

解答上げて良いのか不明なのでまだ伏せておく. 多分全部終わったらgithubに投げる.

今日はGCJやったし競プロはおやすみで良いかなぁ.