use super::types::{
default_mode, error, ApiError, AppState, FuriganaParams, FuriganaResponse, MAX_PAUSE_LEN,
MAX_SEGMENT_LEN, MAX_TEXT_LEN, MIN_SEGMENT_LEN, SLOW_REQUEST_MS,
};
use axum::extract::{ConnectInfo, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use furigana::{Furigana, RomajiStyle, TtsOptions};
use serde_json::{json, Value};
use std::net::SocketAddr;
use std::time::Instant;
#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub(super) enum ReloadSource {
Startup,
Admin,
AutoUpdate,
Sighup,
}
impl ReloadSource {
fn as_str(&self) -> &'static str {
match self {
Self::Startup => "startup",
Self::Admin => "admin",
Self::AutoUpdate => "auto_update",
Self::Sighup => "sighup",
}
}
}
pub(super) async fn healthz(State(state): State<AppState>) -> Json<Value> {
let f = state.furigana.read().await;
Json(json!({
"status": "ok",
"dict_size": f.dict_size(),
}))
}
pub(super) async fn metrics(State(state): State<AppState>) -> impl IntoResponse {
let body = state.metrics.render();
(
StatusCode::OK,
[("content-type", "text/plain; version=0.0.4")],
body,
)
}
pub(super) async fn furigana_get(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Query(params): Query<FuriganaParams>,
) -> Result<Json<FuriganaResponse>, ApiError> {
let f = state.furigana.read().await.clone();
let user_agent = ua(&headers);
process(f.as_ref(), ¶ms, &state, peer, user_agent.as_deref())
}
pub(super) async fn furigana_post(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(params): Json<FuriganaParams>,
) -> Result<Json<FuriganaResponse>, ApiError> {
let f = state.furigana.read().await.clone();
let user_agent = ua(&headers);
process(f.as_ref(), ¶ms, &state, peer, user_agent.as_deref())
}
pub(super) async fn admin_reload(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
let dict_size = do_reload(&state, ReloadSource::Admin).await.map_err(|e| {
error(
StatusCode::INTERNAL_SERVER_ERROR,
format!("reload failed: {e}"),
)
})?;
Ok(Json(json!({
"status": "reloaded",
"dict_size": dict_size,
})))
}
pub(super) async fn do_reload(state: &AppState, source: ReloadSource) -> Result<usize, String> {
let old_size = state.furigana.read().await.dict_size();
let paths = state.paths.clone();
let new = tokio::task::spawn_blocking(move || -> Result<furigana::Furigana, String> {
let f = crate::commands::build_furigana(&paths)
.map_err(|e| format!("build_furigana failed: {e}"))?;
f.preload()
.map_err(|e| format!("preload after reload failed: {e}"))?;
Ok(f)
})
.await
.map_err(|e| format!("reload task join error: {e}"))??;
let new_arc = std::sync::Arc::new(new);
let dict_size = new_arc.dict_size();
*state.furigana.write().await = new_arc;
state.metrics.record_reload(dict_size);
tracing::info!(
source = source.as_str(),
old_size,
new_size = dict_size,
delta = dict_size as i64 - old_size as i64,
"dict reload"
);
Ok(dict_size)
}
fn ua(headers: &HeaderMap) -> Option<String> {
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
fn process(
f: &Furigana,
params: &FuriganaParams,
state: &AppState,
peer: SocketAddr,
user_agent: Option<&str>,
) -> Result<Json<FuriganaResponse>, ApiError> {
let text = decode_text(params)?;
validate_length(&text)?;
validate_params(params)?;
let mode = normalize_mode(¶ms.mode);
tracing::debug!(
peer = %peer,
user_agent = user_agent.unwrap_or("-"),
text = %text,
mode = %mode,
"request"
);
let t_start = Instant::now();
if mode == "analyze" {
let analyze_start = Instant::now();
let analyze_result = f.analyze(&text);
let t_convert_ms = analyze_start.elapsed().as_secs_f64() * 1000.0;
let t_total_ms = t_start.elapsed().as_secs_f64() * 1000.0;
let result: String = analyze_result
.tokens
.iter()
.map(|t| t.reading.as_str())
.collect();
let timings_ms = if params.debug {
Some(json!({
"total": round1(t_total_ms),
"tokenize": 0.0, "convert": round1(t_convert_ms),
}))
} else {
None
};
let token_dump = format_analyze_tokens(&analyze_result);
state.metrics.record_request(&mode, t_total_ms);
let degraded = detect_degraded(&mode, &text, &result);
if degraded {
state.metrics.record_failed_resolution();
tracing::warn!(
peer = %peer,
text = %text,
result = %result,
mode = %mode,
"reading resolution degraded (= empty or identity to input)"
);
}
if t_total_ms > SLOW_REQUEST_MS {
state.metrics.record_slow_request();
tracing::warn!(
peer = %peer,
mode = %mode,
text_len = text.chars().count(),
total_ms = round1(t_total_ms),
"slow request"
);
}
tracing::debug!(
peer = %peer,
result = %result,
tokens = %token_dump,
n_tokens = analyze_result.tokens.len(),
total_ms = round1(t_total_ms),
convert_ms = round1(t_convert_ms),
"response (analyze)"
);
return Ok(Json(FuriganaResponse {
result,
mode,
segments: None,
timings_ms,
analyze: Some(analyze_result),
accent: None,
}));
}
if mode == "accent" {
let accent_start = Instant::now();
let accent_result = f.to_accent(&text);
let t_convert_ms = accent_start.elapsed().as_secs_f64() * 1000.0;
let t_total_ms = t_start.elapsed().as_secs_f64() * 1000.0;
let result: String = accent_result
.tokens
.iter()
.map(|t| t.reading.as_str())
.collect();
let timings_ms = if params.debug {
Some(json!({
"total": round1(t_total_ms),
"tokenize": 0.0,
"convert": round1(t_convert_ms),
}))
} else {
None
};
state.metrics.record_request(&mode, t_total_ms);
return Ok(Json(FuriganaResponse {
result,
mode,
segments: None,
timings_ms,
analyze: None,
accent: Some(accent_result),
}));
}
let tokens_start = Instant::now();
let tokens = f.tokenize(&text);
let t_tokenize_ms = tokens_start.elapsed().as_secs_f64() * 1000.0;
let convert_start = Instant::now();
let result = match mode.as_str() {
"kanji" => text.clone(),
"ruby" => furigana::tokens_to_ruby(&tokens),
"hiragana" => furigana::tokens_to_hiragana(&tokens),
"romaji" => {
let hira = furigana::tokens_to_hiragana(&tokens);
furigana::hiragana_to_romaji(&hira, RomajiStyle::Hepburn)
}
"romaji-kunrei" => {
let hira = furigana::tokens_to_hiragana(&tokens);
furigana::hiragana_to_romaji(&hira, RomajiStyle::Kunrei)
}
_ => {
let opts = TtsOptions {
short_pause: params.short_pause.clone(),
long_pause: params.long_pause.clone(),
keep_period: params.keep_period,
};
let hira = furigana::tokens_to_hiragana(&tokens);
furigana::tts::normalize_for_tts(&hira, &opts)
}
};
let t_convert_ms = convert_start.elapsed().as_secs_f64() * 1000.0;
let t_total_ms = t_start.elapsed().as_secs_f64() * 1000.0;
let segments = if params.segmented && (mode == "tts" || mode == "hiragana") {
Some(furigana::tts::segment_for_tts(
&result,
params.max_segment_len,
))
} else {
None
};
let timings_ms = if params.debug {
Some(json!({
"total": round1(t_total_ms),
"tokenize": round1(t_tokenize_ms),
"convert": round1(t_convert_ms),
}))
} else {
None
};
let token_dump = format_tokens(&tokens);
let n_segments = segments.as_ref().map(|s| s.len()).unwrap_or(0);
state.metrics.record_request(&mode, t_total_ms);
let degraded = detect_degraded(&mode, &text, &result);
if degraded {
state.metrics.record_failed_resolution();
tracing::warn!(
peer = %peer,
text = %text,
result = %result,
mode = %mode,
"reading resolution degraded (= empty or identity to input)"
);
}
if t_total_ms > SLOW_REQUEST_MS {
state.metrics.record_slow_request();
tracing::warn!(
peer = %peer,
mode = %mode,
text_len = text.chars().count(),
total_ms = round1(t_total_ms),
tokenize_ms = round1(t_tokenize_ms),
convert_ms = round1(t_convert_ms),
"slow request"
);
}
tracing::debug!(
peer = %peer,
result = %result,
tokens = %token_dump,
n_tokens = tokens.len(),
n_segments,
total_ms = round1(t_total_ms),
tokenize_ms = round1(t_tokenize_ms),
convert_ms = round1(t_convert_ms),
"response"
);
Ok(Json(FuriganaResponse {
result,
mode,
segments,
timings_ms,
analyze: None,
accent: None,
}))
}
fn detect_degraded(mode: &str, text: &str, result: &str) -> bool {
if mode == "kanji" {
return false;
}
if result.is_empty() {
return true;
}
let has_kanji = text.chars().any(|c| {
('\u{4E00}'..='\u{9FFF}').contains(&c) || ('\u{3400}'..='\u{4DBF}').contains(&c)
});
has_kanji && result == text
}
fn format_tokens(tokens: &[furigana::ReadingToken]) -> String {
tokens
.iter()
.map(|t| format!("{}[{}]", t.surface, t.reading.as_deref().unwrap_or("")))
.collect::<Vec<_>>()
.join("|")
}
fn format_analyze_tokens(result: &furigana::AnalyzeResult) -> String {
result
.tokens
.iter()
.map(|t| format!("{}[{}]", t.surface, t.reading))
.collect::<Vec<_>>()
.join("|")
}
fn decode_text(params: &FuriganaParams) -> Result<String, ApiError> {
if let Some(b64) = params.text_b64.as_ref() {
let decoded = URL_SAFE_NO_PAD
.decode(b64.trim_end_matches('='))
.map_err(|_| error(StatusCode::BAD_REQUEST, "invalid base64 in text_b64"))?;
String::from_utf8(decoded).map_err(|_| {
error(
StatusCode::BAD_REQUEST,
"text_b64 decoded bytes are not valid UTF-8",
)
})
} else if let Some(t) = params.text.as_ref() {
Ok(t.clone())
} else {
Err(error(StatusCode::BAD_REQUEST, "no text provided"))
}
}
fn validate_length(text: &str) -> Result<(), ApiError> {
if text.is_empty() {
return Err(error(StatusCode::BAD_REQUEST, "no text provided"));
}
let nchars = text.chars().count();
if nchars > MAX_TEXT_LEN {
return Err(error(
StatusCode::BAD_REQUEST,
format!("text too long: {nchars} chars (max {MAX_TEXT_LEN})"),
));
}
Ok(())
}
fn validate_params(params: &FuriganaParams) -> Result<(), ApiError> {
let short_len = params.short_pause.chars().count();
if short_len > MAX_PAUSE_LEN {
return Err(error(
StatusCode::BAD_REQUEST,
format!("short_pause too long: {short_len} chars (max {MAX_PAUSE_LEN})"),
));
}
let long_len = params.long_pause.chars().count();
if long_len > MAX_PAUSE_LEN {
return Err(error(
StatusCode::BAD_REQUEST,
format!("long_pause too long: {long_len} chars (max {MAX_PAUSE_LEN})"),
));
}
if params.segmented && !(MIN_SEGMENT_LEN..=MAX_SEGMENT_LEN).contains(¶ms.max_segment_len) {
return Err(error(
StatusCode::BAD_REQUEST,
format!(
"max_segment_len out of range: {} (allowed {MIN_SEGMENT_LEN}..={MAX_SEGMENT_LEN})",
params.max_segment_len
),
));
}
Ok(())
}
fn normalize_mode(mode: &str) -> String {
match mode {
"tts" | "hiragana" | "ruby" | "kanji" | "romaji" | "romaji-kunrei" | "analyze"
| "accent" => mode.to_string(),
_ => default_mode(),
}
}
fn round1(ms: f64) -> f64 {
(ms * 10.0).round() / 10.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::serve::types::{default_long_pause, default_max_seg, default_short_pause};
fn base_params() -> FuriganaParams {
FuriganaParams {
text: Some("猫".into()),
text_b64: None,
mode: default_mode(),
short_pause: default_short_pause(),
long_pause: default_long_pause(),
keep_period: true,
segmented: false,
max_segment_len: default_max_seg(),
debug: false,
}
}
#[test]
fn validate_params_accepts_defaults() {
assert!(validate_params(&base_params()).is_ok());
}
#[test]
fn validate_params_rejects_oversized_short_pause() {
let p = FuriganaParams {
short_pause: "x".repeat(MAX_PAUSE_LEN + 1),
..base_params()
};
assert!(validate_params(&p).is_err());
}
#[test]
fn validate_params_rejects_oversized_long_pause() {
let p = FuriganaParams {
long_pause: "x".repeat(MAX_PAUSE_LEN + 1),
..base_params()
};
assert!(validate_params(&p).is_err());
}
#[test]
fn validate_params_accepts_pause_at_limit() {
let p = FuriganaParams {
short_pause: "x".repeat(MAX_PAUSE_LEN),
long_pause: "y".repeat(MAX_PAUSE_LEN),
..base_params()
};
assert!(validate_params(&p).is_ok());
}
#[test]
fn validate_params_rejects_zero_segment_len_when_segmented() {
let p = FuriganaParams {
segmented: true,
max_segment_len: 0,
..base_params()
};
assert!(validate_params(&p).is_err());
}
#[test]
fn validate_params_ignores_segment_len_when_not_segmented() {
let p = FuriganaParams {
segmented: false,
max_segment_len: 0,
..base_params()
};
assert!(validate_params(&p).is_ok());
}
#[test]
fn validate_params_rejects_segment_len_above_max() {
let p = FuriganaParams {
segmented: true,
max_segment_len: MAX_SEGMENT_LEN + 1,
..base_params()
};
assert!(validate_params(&p).is_err());
}
#[test]
fn decode_text_plain_text() {
let p = FuriganaParams {
text: Some("猫".into()),
text_b64: None,
..base_params()
};
assert_eq!(decode_text(&p).unwrap(), "猫");
}
#[test]
fn decode_text_b64_decodes_and_takes_precedence() {
let p = FuriganaParams {
text: Some("犬".into()),
text_b64: Some("54yr".into()),
..base_params()
};
assert_eq!(decode_text(&p).unwrap(), "猫");
}
#[test]
fn decode_text_invalid_base64_is_400() {
let p = FuriganaParams {
text: None,
text_b64: Some("!!!not base64!!!".into()),
..base_params()
};
assert_eq!(decode_text(&p).unwrap_err().0, StatusCode::BAD_REQUEST);
}
#[test]
fn decode_text_invalid_utf8_is_400() {
let p = FuriganaParams {
text: None,
text_b64: Some("_w".into()),
..base_params()
};
assert_eq!(decode_text(&p).unwrap_err().0, StatusCode::BAD_REQUEST);
}
#[test]
fn decode_text_missing_both_is_400() {
let p = FuriganaParams {
text: None,
text_b64: None,
..base_params()
};
assert_eq!(decode_text(&p).unwrap_err().0, StatusCode::BAD_REQUEST);
}
#[test]
fn validate_length_boundaries() {
assert!(validate_length("").is_err(), "空は 400");
assert!(validate_length("あ").is_ok());
assert!(
validate_length(&"あ".repeat(MAX_TEXT_LEN)).is_ok(),
"上限ちょうどは OK"
);
assert_eq!(
validate_length(&"あ".repeat(MAX_TEXT_LEN + 1))
.unwrap_err()
.0,
StatusCode::BAD_REQUEST,
"上限+1 は 400"
);
}
#[test]
fn normalize_mode_known_through_unknown_to_default() {
for m in [
"tts",
"hiragana",
"ruby",
"kanji",
"romaji",
"romaji-kunrei",
"analyze",
"accent",
] {
assert_eq!(normalize_mode(m), m, "known mode はそのまま");
}
assert_eq!(normalize_mode("bogus"), default_mode());
assert_eq!(normalize_mode(""), default_mode());
}
#[test]
fn detect_degraded_logic() {
assert!(
!detect_degraded("kanji", "猫", "猫"),
"kanji mode は常に false"
);
assert!(
detect_degraded("hiragana", "猫", ""),
"空 result は degraded"
);
assert!(
detect_degraded("hiragana", "猫", "猫"),
"漢字含みで result==input は degraded"
);
assert!(
!detect_degraded("hiragana", "猫", "ねこ"),
"解決済は not degraded"
);
assert!(
!detect_degraded("hiragana", "ねこ", "ねこ"),
"漢字なしなら result==input でも not degraded"
);
}
}