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())));
}
}
手元で動かす(配布物)
- ステップ 1 の 1 ファイル版(step01_url.rs)末尾のテストを rustc --edition 2021 --test で走らせる
図やコードに出てくる用語(タップで説明)
学んだ概念を、素朴な例から実務寄りの例まで3つの具体例で確認します。
公式ドキュメントで詳しく ↗