2022/02/18

[Rust][actix-web][shift-jis]csvファイルダウンロード

rust

  • actix-webを使って、csvファイルを作ってダウンロードするエンドポイントを作ったので、それの備忘録です
  • rustでのcsvファイルの作り方や、shift-jisのエンコーディングなどの参考にもなります。
  • 末尾にclient側の実装も添付しています。

依存関係

cargo.toml

以下が今回特に必要になるライブラリです。

※ actix-webはbeta版を使っているので注意してください。

actix-web = "4.0.0-beta.10"
actix-files = "0.6.0-beta.16"
actix-cors = "0.6.0-beta.10"
csv = "1.1.6"
encoding_rs = "0.8.30"

実装

API handler

かなりデフォルメして記載します。

windowsユーザーのために、shift jisにエンコードしています。

use crate::Error;
use actix_files::NamedFile;
use actix_web::http::header::{ContentDisposition, HeaderValue};
use actix_web::{web, HttpResponse};
use chrono::Utc;
use std::path::PathBuf;
use csv::{ByteRecord, WriterBuilder};

pub async fn handler(request: web::HttpRequest) -> Result<HttpResponse, Error> {
    let filename = "download_filename.tsv";
    let now = Utc::now().timestamp_millis();
    let tmp_filename = format!("{}_{}.tsv", &filename, now);
    let tmp_dir = PathBuf::from("/tmp");
    let filepath = tmp_dir.join(tmp_filename.as_str());
    let mut wtr = WriterBuilder::new()
        .from_path(filepath.as_path())?;
    
    let encoding = encoding_rs::SHIFT_JIS;
    // もしUTF8だったら以下(私は開発中はconfig値などでここを動的に切り替えれるようにしています。)
    // let encoding = encoding_rs::UTF_8;

    // header行の出力
    let header = vec![
        "会員ID",
        "姓",
        "名",
        "メールアドレス",
    ];
    let header: Vec<Vec<u8>> = header
        .iter()
        .map(|s| encoding.encode(s).0.as_ref().to_vec())
        .collect();
    let header_record = ByteRecord::from(header);
    wtr.write_byte_record(&header_record)?;

    // 行の出力(本来ならdbから1000件毎とかにデータを取得して、書き込みます。
    let row = vec![
        "1",
        "佐藤",
        "太郎",
        "[email protected]",
    ];
    let row: Vec<Vec<u8>> = row
        .iter()
        .map(|s| encoding.encode(s).0.as_ref().to_vec())
        .collect();
    let row_record = ByteRecord::from(row);
    wtr.write_byte_record(&row_record)?;

    // 書き込み実行
    let _ok = wtr.flush()?;

    let mime_type = "text/csv".parse().unwrap();
    let content_disposition = format!("attachment; filename=\"{}\"", tmp_filename.as_str());
    let content_disposition = HeaderValue::from_str(content_disposition.as_str()).unwrap();
    let content_disposition = ContentDisposition::from_raw(&content_disposition).unwrap();
    // actix_files::NamedFile を使ってresponseに変換します
    let named_file = NamedFile::open(filepath)?
        .set_content_type(mime_type)
        .set_content_disposition(content_disposition);
    Ok(named_file.into_response(&request))
}

CorsのTips

actix_cors::Cors middlewareで、content-disposition headerをexposeするように指定します。

これをすることで、content-disposition headerの値をclient側のjavascriptで読み取れるようになります。

use actix_cors::Cors;
use actix_web::web::Data;
use actix_web::{http::header, App, HttpServer};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // 中略
    HttpServer::new(move || {
        let mut cors = Cors::default();
        for allowed_origin in app_config.allowed_origins.as_slice() {
            cors = cors.allowed_origin(allowed_origin.as_str())
        }
        let cors = cors
            .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "PATCH"])
            .allowed_headers(vec![
                header::AUTHORIZATION,
                header::CONTENT_TYPE,
                header::ACCEPT,
            ])
            .expose_headers(vec![header::CONTENT_DISPOSITION])  // ★ ここがポイント
            .max_age(3600);
        App::new()
            .app_data(Data::new(psql_connection_pool.clone()))
            // その他省略
            .wrap(cors)
            // その他省略
            .configure(endpoint::routes)
    })
    .bind(app_config.bind_address.as_str())?
    .run()
    .await
}

Client側

ダウンロードボタンクリック時などに、以下のようにして上記のendpointを呼び出します。

const response = await axios.get('/your-download-endpoint-path', {
  responseType: 'blob',
})
const blob = new Blob([response.data], {
  type: 'text/csv',
})
const headers = response.headers['content-disposition'].split(';')
const target = headers.find((item) => item.includes('filename='))
const fileName = target.trim().replace(/"/g, '').split('=')[1]

if (window.navigator.msSaveOrOpenBlob) {
  // for IE,Edge
  window.navigator.msSaveOrOpenBlob(blob, fileName)
} else {
  const url = window.URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.setAttribute('download', fileName)
  document.body.appendChild(link)
  link.click()
  window.URL.revokeObjectURL(url)
  link.parentNode.removeChild(link)
}

以上です。