use std::borrow::Cow;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use reqwest::{
Method, StatusCode,
header::{AUTHORIZATION, HeaderMap, HeaderValue},
};
use serde::{Deserialize, Serialize};
const PREFIX_URL: &str = "https://api.line.me";
const ENV_KEY: &str = "LINE_API_PREFIX_URL";
const REDACTED: &str = "***";
#[derive(Clone)]
pub struct LineRequestLog<'a> {
captured: Option<&'a CapturedRequest>,
body: &'a serde_json::Value,
redacted_body_keys: &'a [String],
}
#[derive(Clone)]
pub(crate) struct CapturedRequest {
pub(crate) headers: HeaderMap,
pub(crate) method: Method,
pub(crate) path: String,
pub(crate) query: Option<String>,
}
impl std::fmt::Debug for LineRequestLog<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LineRequestLog")
.field("method", &self.method())
.field("path", &self.path())
.field("query", &self.query_redacted())
.field("headers", &self.headers_redacted())
.field("body", &self.body_redacted())
.finish()
}
}
impl<'a> LineRequestLog<'a> {
pub(crate) fn new(
captured: Option<&'a CapturedRequest>,
body: &'a serde_json::Value,
redacted_body_keys: &'a [String],
) -> Self {
Self {
captured,
body,
redacted_body_keys,
}
}
pub fn method(&self) -> Option<&'a Method> {
self.captured.map(|c| &c.method)
}
pub fn path(&self) -> Option<&'a str> {
self.captured.map(|c| c.path.as_str())
}
pub fn query(&self) -> Option<&'a str> {
self.captured.and_then(|c| c.query.as_deref())
}
pub fn query_redacted(&self) -> Option<String> {
self.query()
.map(|q| redact_query(q, self.redacted_body_keys))
}
pub fn headers(&self) -> Option<&'a HeaderMap> {
self.captured.map(|c| &c.headers)
}
pub fn body(&self) -> &'a serde_json::Value {
self.body
}
pub fn headers_redacted(&self) -> Option<HeaderMap> {
self.headers().map(redact_headers)
}
pub fn body_redacted(&self) -> serde_json::Value {
redact_body(self.body, self.redacted_body_keys)
}
}
#[derive(Debug, Clone)]
pub enum ResponseBody {
Json(serde_json::Value),
Raw(String),
}
#[derive(Clone)]
pub struct LineResponseLog<'a> {
headers: &'a HeaderMap,
body: ResponseBody,
status_code: StatusCode,
redacted_body_keys: &'a [String],
}
impl std::fmt::Debug for LineResponseLog<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LineResponseLog")
.field("headers", &self.headers)
.field("status_code", &self.status_code)
.field("body", &self.body_redacted())
.finish()
}
}
impl<'a> LineResponseLog<'a> {
pub(crate) fn new(
headers: &'a HeaderMap,
body: ResponseBody,
status_code: StatusCode,
redacted_body_keys: &'a [String],
) -> Self {
Self {
headers,
body,
status_code,
redacted_body_keys,
}
}
pub fn headers(&self) -> &'a HeaderMap {
self.headers
}
pub fn as_value(&self) -> Cow<'_, serde_json::Value> {
match &self.body {
ResponseBody::Json(value) => Cow::Borrowed(value),
ResponseBody::Raw(text) => Cow::Owned(serde_json::Value::String(text.clone())),
}
}
pub fn status_code(&self) -> StatusCode {
self.status_code
}
pub fn body_was_json(&self) -> bool {
matches!(self.body, ResponseBody::Json(_))
}
pub fn body_redacted(&self) -> serde_json::Value {
redact_body(&self.as_value(), self.redacted_body_keys)
}
}
pub const REDACTED_BODY_KEYS: &[&str] = &[
"access_token",
"refresh_token",
"client_secret",
"code",
"code_verifier",
"id_token",
"useraccesstoken",
];
static DEFAULT_REDACTED_BODY_KEYS: LazyLock<Vec<String>> =
LazyLock::new(|| REDACTED_BODY_KEYS.iter().map(|s| s.to_string()).collect());
fn redact_headers(headers: &HeaderMap) -> HeaderMap {
let mut redacted = headers.clone();
if redacted.contains_key(AUTHORIZATION) {
redacted.insert(AUTHORIZATION, HeaderValue::from_static(REDACTED));
}
redacted
}
fn redact_body(value: &serde_json::Value, keys: &[String]) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => serde_json::Value::Object(
map.iter()
.map(|(key, val)| {
if keys.contains(&key.to_ascii_lowercase()) {
(key.clone(), serde_json::Value::String(REDACTED.to_string()))
} else {
(key.clone(), redact_body(val, keys))
}
})
.collect(),
),
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(|item| redact_body(item, keys)).collect())
}
other => other.clone(),
}
}
fn redact_query(query: &str, keys: &[String]) -> String {
let pairs = url::form_urlencoded::parse(query.as_bytes()).map(|(key, value)| {
if keys.contains(&key.to_ascii_lowercase()) {
(key.into_owned(), REDACTED.to_string())
} else {
(key.into_owned(), value.into_owned())
}
});
url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(pairs)
.finish()
}
pub(crate) fn serialize_log_body<T: Serialize>(value: &T) -> serde_json::Value {
serde_json::to_value(value)
.unwrap_or_else(|err| serde_json::json!({ "_serialize_error": err.to_string() }))
}
pub(crate) fn run_log_callback(label: &str, f: impl FnOnce()) {
if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
let msg = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "<non-string panic payload>".to_string());
tracing::error!(
callback = label,
panic = %msg,
"LineOptions callback panicked; ignored to keep the API call alive"
);
}
}
pub type OnRequest = Arc<dyn Fn(&LineRequestLog) + Send + Sync>;
pub type OnResponse = Arc<dyn Fn(&LineRequestLog, &LineResponseLog) + Send + Sync>;
#[derive(Default, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LineOptions {
pub(crate) prefix_url: Option<String>,
pub(crate) timeout_duration: Option<Duration>,
pub(crate) try_count: Option<u8>,
pub(crate) retry_duration: Option<Duration>,
#[serde(skip)]
pub(crate) on_request: Option<OnRequest>,
#[serde(skip)]
pub(crate) on_response: Option<OnResponse>,
pub(crate) redacted_body_keys: Option<Vec<String>>,
}
impl std::fmt::Debug for LineOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LineOptions")
.field("prefix_url", &self.prefix_url)
.field("timeout_duration", &self.timeout_duration)
.field("try_count", &self.try_count)
.field("retry_duration", &self.retry_duration)
.field("on_request", &self.on_request.as_ref().map(|_| "Fn"))
.field("on_response", &self.on_response.as_ref().map(|_| "Fn"))
.field("redacted_body_keys", &self.redacted_body_keys)
.finish()
}
}
impl LineOptions {
pub fn builder() -> LineOptionsBuilder {
LineOptionsBuilder::default()
}
pub fn get_try_count(&self) -> u8 {
self.try_count.unwrap_or(1).max(1)
}
pub fn get_retry_duration(&self) -> Duration {
self.retry_duration.unwrap_or(Duration::from_secs(0))
}
pub fn get_timeout_duration(&self) -> Duration {
self.timeout_duration.unwrap_or(Duration::from_secs(0))
}
pub fn get_prefix_url(&self) -> String {
self.resolve_prefix_url()
}
pub fn get_redacted_body_keys(&self) -> &[String] {
self.redacted_body_keys
.as_deref()
.unwrap_or_else(|| &DEFAULT_REDACTED_BODY_KEYS)
}
pub(crate) fn resolve_prefix_url(&self) -> String {
self.prefix_url
.clone()
.unwrap_or_else(|| std::env::var(ENV_KEY).unwrap_or_else(|_| PREFIX_URL.to_string()))
}
}
#[derive(Default, Clone)]
pub struct LineOptionsBuilder {
prefix_url: Option<String>,
timeout_duration: Option<Duration>,
try_count: Option<u8>,
retry_duration: Option<Duration>,
on_request: Option<OnRequest>,
on_response: Option<OnResponse>,
redacted_body_keys: Option<Vec<String>>,
}
impl std::fmt::Debug for LineOptionsBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LineOptionsBuilder")
.field("prefix_url", &self.prefix_url)
.field("timeout_duration", &self.timeout_duration)
.field("try_count", &self.try_count)
.field("retry_duration", &self.retry_duration)
.field("on_request", &self.on_request.as_ref().map(|_| "Fn"))
.field("on_response", &self.on_response.as_ref().map(|_| "Fn"))
.field("redacted_body_keys", &self.redacted_body_keys)
.finish()
}
}
impl LineOptionsBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn build(self) -> LineOptions {
LineOptions {
prefix_url: self.prefix_url,
timeout_duration: self.timeout_duration,
try_count: self.try_count,
retry_duration: self.retry_duration,
on_request: self.on_request,
on_response: self.on_response,
redacted_body_keys: self.redacted_body_keys,
}
}
pub fn with_prefix_url(mut self, prefix_url: impl Into<String>) -> Self {
self.prefix_url = Some(prefix_url.into());
self
}
pub fn with_timeout_duration(mut self, timeout_duration: Duration) -> Self {
self.timeout_duration = Some(timeout_duration);
self
}
pub fn with_try_count(mut self, try_count: u8) -> Self {
self.try_count = Some(try_count);
self
}
pub fn with_retry_duration(mut self, retry_duration: Duration) -> Self {
self.retry_duration = Some(retry_duration);
self
}
pub fn with_redacted_body_keys<I, S>(mut self, keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.redacted_body_keys = Some(
keys.into_iter()
.map(|s| s.as_ref().to_ascii_lowercase())
.collect(),
);
self
}
pub fn with_on_request(mut self, f: impl Fn(&LineRequestLog) + Send + Sync + 'static) -> Self {
self.on_request = Some(Arc::new(f));
self
}
pub fn with_on_response(
mut self,
f: impl Fn(&LineRequestLog, &LineResponseLog) + Send + Sync + 'static,
) -> Self {
self.on_response = Some(Arc::new(f));
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_log_body_success() {
let value = serialize_log_body(&serde_json::json!({"a": 1}));
assert_eq!(value, serde_json::json!({"a": 1}));
}
#[test]
fn test_serialize_log_body_failure_sentinel() {
use std::collections::HashMap;
let mut map: HashMap<Vec<i32>, i32> = HashMap::new();
map.insert(vec![1, 2], 3);
let value = serialize_log_body(&map);
assert!(
value.get("_serialize_error").is_some(),
"expected serialize error sentinel, got: {value}"
);
assert_ne!(value, serde_json::Value::Null);
}
#[test]
fn test_redact_body_masks_known_keys_recursively() {
let input = serde_json::json!({
"client_secret": "secret",
"grant_type": "authorization_code",
"nested": { "refresh_token": "rt", "keep": "v" },
"list": [ { "access_token": "at" } ],
});
let out = redact_body(&input, &DEFAULT_REDACTED_BODY_KEYS);
assert_eq!(out["client_secret"], "***");
assert_eq!(out["grant_type"], "authorization_code");
assert_eq!(out["nested"]["refresh_token"], "***");
assert_eq!(out["nested"]["keep"], "v");
assert_eq!(out["list"][0]["access_token"], "***");
}
#[test]
fn test_redact_body_case_insensitive() {
let input = serde_json::json!({ "userAccessToken": "x", "ID_TOKEN": "y" });
let out = redact_body(&input, &DEFAULT_REDACTED_BODY_KEYS);
assert_eq!(out["userAccessToken"], "***");
assert_eq!(out["ID_TOKEN"], "***");
}
#[test]
fn test_redact_deauthorize_request_body() {
use crate::line_login::post_user_v1_deauthorize::RequestBody;
let body = RequestBody {
user_access_token: "super-secret-token".to_string(),
};
let value = serialize_log_body(&body);
assert_eq!(value["userAccessToken"], "super-secret-token");
let redacted = redact_body(&value, &DEFAULT_REDACTED_BODY_KEYS);
assert_eq!(redacted["userAccessToken"], "***");
}
#[test]
fn test_get_try_count_normalizes_zero() {
assert_eq!(LineOptions::default().get_try_count(), 1, "None は 1");
assert_eq!(
LineOptions::builder()
.with_try_count(0)
.build()
.get_try_count(),
1,
"0 は 1 に正規化"
);
assert_eq!(
LineOptions::builder()
.with_try_count(3)
.build()
.get_try_count(),
3,
"それ以外はそのまま"
);
assert_eq!(
LineOptions::builder().with_try_count(0).build().try_count,
Some(0)
);
}
#[test]
fn test_line_options_serde_round_trip_drops_callbacks() {
let options = LineOptions::builder()
.with_prefix_url("https://example.com")
.with_try_count(3)
.with_on_request(|_log| {})
.with_on_response(|_req, _res| {})
.build();
assert!(options.on_request.is_some());
let json = serde_json::to_string(&options).unwrap();
let restored: LineOptions = serde_json::from_str(&json).unwrap();
assert_eq!(restored.prefix_url.as_deref(), Some("https://example.com"));
assert_eq!(restored.try_count, Some(3));
assert!(restored.on_request.is_none());
assert!(restored.on_response.is_none());
}
fn make_captured(method: Method, path: &str, query: Option<&str>) -> CapturedRequest {
CapturedRequest {
headers: HeaderMap::new(),
method,
path: path.to_string(),
query: query.map(|q| q.to_string()),
}
}
#[test]
fn test_request_log_headers_none_contract() {
let body = serde_json::Value::Null;
let log = LineRequestLog::new(None, &body, &DEFAULT_REDACTED_BODY_KEYS);
assert!(log.headers().is_none());
assert!(log.headers_redacted().is_none());
assert!(log.method().is_none());
assert!(log.path().is_none());
assert!(log.query().is_none());
assert!(log.query_redacted().is_none());
}
#[test]
fn test_request_log_method_path_query_accessors() {
let body = serde_json::Value::Null;
let captured = make_captured(
Method::GET,
"/v2/bot/message/aggregation/list",
Some("limit=5&start=abc"),
);
let log = LineRequestLog::new(Some(&captured), &body, &DEFAULT_REDACTED_BODY_KEYS);
assert_eq!(log.method(), Some(&Method::GET));
assert_eq!(log.path(), Some("/v2/bot/message/aggregation/list"));
assert_eq!(log.query(), Some("limit=5&start=abc"));
assert_eq!(log.query_redacted().as_deref(), Some("limit=5&start=abc"));
}
#[test]
fn test_request_log_query_none_but_captured() {
let body = serde_json::Value::Null;
let captured = make_captured(Method::POST, "/v2/bot/message/push", None);
let log = LineRequestLog::new(Some(&captured), &body, &DEFAULT_REDACTED_BODY_KEYS);
assert_eq!(log.method(), Some(&Method::POST));
assert_eq!(log.path(), Some("/v2/bot/message/push"));
assert!(log.query().is_none());
assert!(log.query_redacted().is_none());
}
#[test]
fn test_request_log_query_redacted_masks_access_token() {
let body = serde_json::Value::Null;
let captured = make_captured(
Method::GET,
"/oauth2/v2.1/verify",
Some("access_token=super-secret&keep=v"),
);
let log = LineRequestLog::new(Some(&captured), &body, &DEFAULT_REDACTED_BODY_KEYS);
assert_eq!(log.query(), Some("access_token=super-secret&keep=v"));
let redacted = log.query_redacted().unwrap();
assert_eq!(redacted, "access_token=***&keep=v");
assert!(
!redacted.contains("super-secret"),
"生トークンが残っている: {redacted}"
);
}
#[test]
fn test_request_log_query_redacted_uses_per_call_keys() {
let body = serde_json::Value::Null;
let custom_keys = ["mysecret".to_string()];
let captured = make_captured(
Method::GET,
"/oauth2/v2.1/verify",
Some("mysecret=s&access_token=at"),
);
let log = LineRequestLog::new(Some(&captured), &body, &custom_keys);
assert_eq!(
log.query_redacted().as_deref(),
Some("mysecret=***&access_token=at")
);
}
#[test]
fn test_request_log_debug_masks_query_secret() {
let body = serde_json::Value::Null;
let captured = make_captured(
Method::GET,
"/oauth2/v2.1/verify",
Some("access_token=super-secret&keep=v"),
);
let log = LineRequestLog::new(Some(&captured), &body, &DEFAULT_REDACTED_BODY_KEYS);
let dbg = format!("{log:?}");
assert!(dbg.contains("***"), "Debug にマスク表現が無い: {dbg}");
assert!(
!dbg.contains("super-secret"),
"生トークンが Debug 出力に漏れている: {dbg}"
);
}
#[test]
fn test_redact_query_masks_duplicate_keys() {
let out = redact_query("access_token=a&access_token=b", &DEFAULT_REDACTED_BODY_KEYS);
assert_eq!(out, "access_token=***&access_token=***");
}
#[test]
fn test_redact_query_roundtrip_encoding() {
assert_eq!(redact_query("q=a+b", &DEFAULT_REDACTED_BODY_KEYS), "q=a+b");
assert_eq!(
redact_query("q=a%20b", &DEFAULT_REDACTED_BODY_KEYS),
"q=a+b"
);
}
#[test]
fn test_redact_query_custom_keys() {
let keys = vec!["mysecret".to_string()];
let out = redact_query("mySecret=s&access_token=at", &keys);
assert_eq!(out, "mySecret=***&access_token=at");
}
#[test]
fn test_redact_query_empty() {
assert_eq!(redact_query("", &DEFAULT_REDACTED_BODY_KEYS), "");
}
#[test]
fn test_redact_body_masks_generic_code_in_response() {
let response = serde_json::json!({
"message": "invalid request",
"code": "40000",
});
let redacted = redact_body(&response, &DEFAULT_REDACTED_BODY_KEYS);
assert_eq!(redacted["code"], "***", "汎用 code も意図的にマスクされる");
assert_eq!(redacted["message"], "invalid request");
}
#[test]
fn test_get_redacted_body_keys_default_matches_const() {
let keys = LineOptions::default();
let keys = keys.get_redacted_body_keys();
assert_eq!(keys.len(), REDACTED_BODY_KEYS.len());
for k in REDACTED_BODY_KEYS {
assert!(keys.iter().any(|x| x == k), "default に {k} が無い");
}
}
#[test]
fn test_with_redacted_body_keys_replaces_and_normalizes() {
let options = LineOptions::builder()
.with_redacted_body_keys(["mySecret", "apiKey"])
.build();
assert_eq!(
options.get_redacted_body_keys(),
&["mysecret".to_string(), "apikey".to_string()]
);
let input = serde_json::json!({
"mySecret": "s",
"apiKey": "k",
"access_token": "at",
});
let out = redact_body(&input, options.get_redacted_body_keys());
assert_eq!(out["mySecret"], "***");
assert_eq!(out["apiKey"], "***");
assert_eq!(out["access_token"], "at");
}
#[test]
fn test_with_redacted_body_keys_empty_disables_masking() {
let options = LineOptions::builder()
.with_redacted_body_keys(Vec::<String>::new())
.build();
let input = serde_json::json!({ "access_token": "at" });
let out = redact_body(&input, options.get_redacted_body_keys());
assert_eq!(out["access_token"], "at", "空指定ならマスクされない");
}
}