Dev Study
← Rust で小さなブラウザを作る コース

6. 失敗を型にする — UrlError と Result で「どう壊れていたか」を返す

分解に失敗したとき、呼び出し側が知りたいのは「失敗した」ことではなく「どう壊れていたか」です。参照実装は失敗を UrlError の 4 つ(MissingScheme / UnsupportedScheme / EmptyHost / InvalidPort)で返し、parse の戻り値は <Url, UrlError> です。

4 つは parse の 4 段にそれぞれ対応しています。"://" が無ければ MissingScheme、http 以外なら UnsupportedScheme(名を持たせる)、 が空なら EmptyHost、 が 0〜65535 の整数でなければ InvalidPort(元の文字列を持たせる)。で途中の失敗を即座に返すので、成功の道筋だけが上から下へ読めます。

UrlError には Display を実装してあり、人向けのメッセージ(「対応していないスキームです: (http だけ)」)が出ます。 はこれをそのまま表示して終了コード 1 で終わります。エラー型に Debug と PartialEq を導出してあるのは、テストで (parse("example.com"), Err(UrlError::MissingScheme)) と書けるようにするためです。

テストは「成功の形」と「失敗の形」を別々に書きます。成功側は host だけ・port と つき・無しで query だけ・ が捨てられる・大文字が小文字になる、の 5 つ。失敗側は 4 種類のエラーが狙いどおり出るか。1 つのテスト関数に 1 つの性質、というのはこの先の全部の章で同じです。

本物のブラウザでは: の canonicalizer は「危険または不正なデータについては が不正だと報告するが、常に最善の出力を作ろうとする」(url/README.md)という方針です。アドレス欄に人が打つ文字列を受けるので、Err で止めるより『直せる限り直して、不正なら印を付ける』を選んでいます。この回の Result<Url, UrlError> は逆に厳しい側に寄せていて、どこで何を拒んだかを型で見せるためです。両方の立場を知っていると、ライブラリの URL 関数が『落ちない』理由と『黙って直す』理由が読めます。

開発のどこに活かすか: 文字列のエラーメッセージを返す設計は、呼び出し側が文字列を見て分岐し始めた時点で壊れます。「失敗の種類を enum にして、必要な情報(どのスキームだったか、どのポートだったか)をその中に持たせる」のは、入力検証・設定ファイルの読み込み・外部 API の応答の解釈のどれにも効きます。

点線の付いた語はタップすると説明が出ます。用語集を開く →

サンプルコード(フレームワーク環境が必要なため表示のみ)

// src/url.rs(テスト込み)
//! URL を部品に分ける。
//!
//! 仕様は WHATWG URL Standard(https://url.spec.whatwg.org/)。
//! URL は scheme / host / port / path / query / fragment からなり、http は「特別なスキーム」で既定ポートは 80。
//!
//! この実装の割り切り:
//! - 受け付けるスキームは http だけ(TLS を実装しないので https は扱えない)
//! - 相対 URL、パーセント符号化、IPv6 のホスト表記、ユーザー情報(user:pass@)は扱わない
//! - fragment(#以降)はサーバーに送らないので読み捨てる

use std::fmt;

/// http の既定ポート(URL Standard の special scheme の表より)
pub const HTTP_DEFAULT_PORT: u16 = 80;

/// 分解した URL。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Url {
    pub scheme: String,
    pub host: String,
    pub port: u16,
    /// 先頭の "/" を含む。URL にパスが無ければ "/"
    pub path: String,
    /// "?" より後ろ。無ければ None
    pub query: Option<String>,
}

/// 分解に失敗した理由。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UrlError {
    /// "://" が無い
    MissingScheme,
    /// http 以外のスキーム
    UnsupportedScheme(String),
    /// ホストが空(例: http:///path)
    EmptyHost,
    /// ポートが 0〜65535 の整数でない
    InvalidPort(String),
}

impl fmt::Display for UrlError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            UrlError::MissingScheme => write!(f, "スキームがありません(例: http://...)"),
            UrlError::UnsupportedScheme(s) => write!(f, "対応していないスキームです: {s}(http だけ)"),
            UrlError::EmptyHost => write!(f, "ホストが空です"),
            UrlError::InvalidPort(p) => write!(f, "ポートが不正です: {p:?}"),
        }
    }
}

impl std::error::Error for UrlError {}

/// 文字列を URL に分解する。
///
/// 手順は左から右へ 4 段階:
/// 1. "://" の前がスキーム
/// 2. 次の "/" か "?" までが authority(host[:port])
/// 3. authority を ":" で host と port に分ける(無ければ 80)
/// 4. 残りを "?" で path と query に分ける
pub fn parse(input: &str) -> Result<Url, UrlError> {
    let input = input.trim();

    // 1. スキーム
    let (scheme, rest) = input.split_once("://").ok_or(UrlError::MissingScheme)?;
    let scheme = scheme.to_ascii_lowercase();
    if scheme != "http" {
        return Err(UrlError::UnsupportedScheme(scheme));
    }

    // fragment はクライアント内でだけ使うもの。サーバーには送らないのでここで捨てる
    let rest = rest.split('#').next().unwrap_or("");

    // 2. authority の終わり = 最初の "/" か "?"
    let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
    let authority = &rest[..authority_end];
    let tail = &rest[authority_end..];

    // 3. host と port
    let (host, port) = match authority.rsplit_once(':') {
        Some((h, p)) if p.is_empty() => (h, HTTP_DEFAULT_PORT), // "host:" は既定ポート扱い
        Some((h, p)) => {
            let port = p
                .parse::<u16>()
                .map_err(|_| UrlError::InvalidPort(p.to_string()))?;
            (h, port)
        }
        None => (authority, HTTP_DEFAULT_PORT),
    };
    if host.is_empty() {
        return Err(UrlError::EmptyHost);
    }

    // 4. path と query
    let (path, query) = match tail.split_once('?') {
        Some((p, q)) => (p, Some(q.to_string())),
        None => (tail, None),
    };
    let path = if path.is_empty() { "/".to_string() } else { path.to_string() };

    Ok(Url {
        scheme,
        host: host.to_ascii_lowercase(),
        port,
        path,
        query,
    })
}

impl Url {
    /// HTTP の request-target(origin-form)。path と ?query をつないだもの
    pub fn request_target(&self) -> String {
        match &self.query {
            Some(q) => format!("{}?{}", self.path, q),
            None => self.path.clone(),
        }
    }

    /// Host ヘッダーの値。既定ポート以外のときだけ ":port" を付ける(RFC 9110 §7.2)
    pub fn host_header(&self) -> String {
        if self.port == HTTP_DEFAULT_PORT {
            self.host.clone()
        } else {
            format!("{}:{}", self.host, self.port)
        }
    }
}

impl fmt::Display for Url {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}://{}{}", self.scheme, self.host_header(), self.request_target())
    }
}

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

    #[test]
    fn host_only() {
        let u = parse("http://example.com").unwrap();
        assert_eq!(u.host, "example.com");
        assert_eq!(u.port, 80);
        assert_eq!(u.path, "/");
        assert_eq!(u.query, None);
    }

    #[test]
    fn port_path_query() {
        let u = parse("http://localhost:8000/docs/index.html?q=rust&page=2").unwrap();
        assert_eq!(u.port, 8000);
        assert_eq!(u.path, "/docs/index.html");
        assert_eq!(u.query.as_deref(), Some("q=rust&page=2"));
        assert_eq!(u.request_target(), "/docs/index.html?q=rust&page=2");
        assert_eq!(u.host_header(), "localhost:8000");
    }

    #[test]
    fn query_without_path() {
        let u = parse("http://example.com?x=1").unwrap();
        assert_eq!(u.path, "/");
        assert_eq!(u.query.as_deref(), Some("x=1"));
    }

    #[test]
    fn fragment_is_dropped() {
        let u = parse("http://example.com/a#section-2").unwrap();
        assert_eq!(u.path, "/a");
        assert_eq!(u.query, None);
    }

    #[test]
    fn scheme_and_host_are_lowercased() {
        let u = parse("HTTP://Example.COM/Path").unwrap();
        assert_eq!(u.scheme, "http");
        assert_eq!(u.host, "example.com");
        assert_eq!(u.path, "/Path"); // パスは大文字小文字を区別する
    }

    #[test]
    fn errors() {
        assert_eq!(parse("example.com"), Err(UrlError::MissingScheme));
        assert_eq!(parse("https://example.com"), Err(UrlError::UnsupportedScheme("https".into())));
        assert_eq!(parse("http:///path"), Err(UrlError::EmptyHost));
        assert_eq!(parse("http://example.com:80a/"), Err(UrlError::InvalidPort("80a".into())));
        assert_eq!(parse("http://example.com:70000/"), Err(UrlError::InvalidPort("70000".into())));
    }
}

手元で動かす(配布物)

図やコードに出てくる用語(タップで説明)

サンプルコードで身につける(3本) →

学んだ概念を、素朴な例から実務寄りの例まで3つの具体例で確認します。

公式ドキュメントで詳しく ↗