🦀

RustでHatena APIのClientを作った

2024/07/14に公開

公式ドキュメントにない言語で実装したので,知見として残しておきます.Basic認証で使うのであれば他の言語でも参考になるかと思います.

はてなブログのAPIを利用する方法

公式ドキュメントによるとOAuthかWSSEまたは,Basic認証が必要とのことでした.

はてなブログAtomPub を利用するために、クライアントは OAuth 認証、WSSE認証、Basic認証のいずれかを行う必要があります。

https://developer.hatena.ne.jp/ja/documents/blog/apis/atom/#ブログエントリの一覧取得

公式ドキュメントにはPerl,Ruby,Scalaの事例が載っています.

Basic認証をheaderに記載する

Basic認証をつけるには以下のようにはHeaderを追加します.

Authorization: Basic <credentials>

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization

Rustで環境変数からuriとauthenticationを出力する

Clientに全部書き出すとテストができなくなりそうなので分けています.環境変数を読み取り,uriとauthenticationを出力します.

authenticationは先ほどの<credential>に相当します.これは id:password をBase64 Encodingしたものです.

In basic HTTP authentication, a request contains a header field in the form of Authorization: Basic <credentials>, where <credentials> is the Base64 encoding of ID and password joined by a single colon :

https://en.wikipedia.org/wiki/Basic_access_authentication

use base64::engine::general_purpose;
use base64::Engine;
use std::env;
use std::error::Error;


pub(crate) struct HatenaApiConfig {
    user_name: String,
    blog_domain: String,
    api_key: String,
}

impl HatenaApiConfig {
    pub(crate) fn new() -> Result<Self, Box<dyn Error>> {
        // Get environment variables
        let user_name: String = env::var("HATENA_USER_NAME")?;
        let blog_domain: String = env::var("HATENA_BLOG_DOMAIN")?;
        let api_key: String = env::var("HATENA_API_KEY")?;

        // Return HatenaApiConfig instance
        Ok(Self {
            user_name,
            blog_domain,
            api_key,
        })
    }

    // 今回はentryのfeedを取得したいので下記のuriを指定
    pub(crate) fn api_url(&self) -> String {
        format!(
            "https://blog.hatena.ne.jp/{}/{}/atom/entry",
            self.user_name, self.blog_domain
        )
    }

    // ここで `id:pass` を Base64 Encode
    pub(crate) fn authorization(&self) -> String {
        general_purpose::STANDARD.encode(format!("{}:{}", self.user_name, self.api_key))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use dotenv::dotenv;

    fn setup_env_vars() {
        env::set_var("HATENA_USER_NAME", "test_user");
        env::set_var("HATENA_BLOG_DOMAIN", "test_domain");
        env::set_var("HATENA_API_KEY", "test_key");
    }

    fn clear_env_vars() {
        env::remove_var("HATENA_USER_NAME");
        env::remove_var("HATENA_BLOG_DOMAIN");
        env::remove_var("HATENA_API_KEY");
    }

    #[test]
    fn test_new() {
        setup_env_vars();

        let config = HatenaApiConfig::new().unwrap();
        assert_eq!(config.user_name, "test_user");
        assert_eq!(config.blog_domain, "test_domain");
        assert_eq!(config.api_key, "test_key");

        clear_env_vars();
    }

    #[test]
    fn test_api_url() {
        setup_env_vars();

        let config = HatenaApiConfig::new().unwrap();

        assert_eq!(
            config.api_url(),
            "https://blog.hatena.ne.jp/test_user/test_domain/atom/entry"
        );

        clear_env_vars();
    }

    #[test]
    fn test_authorization() {
        dotenv().ok();

        let config = HatenaApiConfig::new().unwrap();
        let user_name: String = env::var("HATENA_USER_NAME").unwrap();
        let api_key: String = env::var("HATENA_API_KEY").unwrap();

        // write assert_eq
        let expected_auth: String =
            general_purpose::STANDARD.encode(format!("{}:{}", user_name, api_key));
        assert_eq!(config.authorization(), expected_auth);
    }
}

Rust製 Hatena API Clientを構成

通常のHTTP RequestにAuthentication credentialを加えて完成です.

use crate::factory::hatena_api::HatenaApiConfig; // いい感じに調整してください
use reqwest::Client;
use std::error::Error;

pub struct HatenaApiClient {
    config: HatenaApiConfig,
    client: Client,
}

impl HatenaApiClient {
    pub fn new(config: HatenaApiConfig) -> Self {
        Self {
            config,
            client: Client::new(),
        }
    }

    pub async fn get_entries(&self) -> Result<String, Box<dyn Error>> {
        let res = self
            .client
            .get(&self.config.api_url())
            .header(
                "Authorization",
                format!("Basic {}", self.config.authorization()),
            )
            .header("Content-Type", "application/xml")
            .send()
            .await?;

        let body = res.text().await?;
        Ok(body)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use dotenv::dotenv;

    #[tokio::test]
    async fn test_get_entries() {
        dotenv().ok();

        let config = HatenaApiConfig::new().unwrap();
        let client = HatenaApiClient::new(config);
        let entries = client.get_entries().await;
        assert_eq!(entries.is_ok(), true);

        let entries = entries.unwrap();
        assert_eq!(entries.contains("<entry>"), true);
    }
}

Test

テスト結果は以下になります.(Private Projectのため,一部文字列を変更しています)

 cargo test
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.35s
     Running unittests src/main.rs (target/debug/deps/***)

running 7 tests
...
test factory::hatena_api::tests::test_new ... ok
test factory::hatena_api::tests::test_api_url ... ok
test factory::hatena_api::tests::test_authorization ... ok
test api_client::hatena_api::tests::test_get_entries ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.58s

Discussion