Skip to main content

bpi_rs/
request.rs

1#[cfg(feature = "manga")]
2use crate::response::ApiEnvelope;
3use crate::{
4    BpiError,
5    transport::{ReqwestTransport, TransportEnvelope, TransportResponse},
6};
7use reqwest::RequestBuilder;
8use serde::de::DeserializeOwned;
9use tokio::time::Instant;
10use tracing;
11
12/// 找到不超过 index 的最近合法 UTF-8 字符边界
13#[cfg(any(test, debug_assertions))]
14fn floor_char_boundary(s: &str, index: usize) -> usize {
15    if index >= s.len() {
16        return s.len();
17    }
18    let mut i = index;
19    while i > 0 && !s.is_char_boundary(i) {
20        i -= 1;
21    }
22    i
23}
24
25pub trait BilibiliRequest {
26    fn with_bilibili_headers(self) -> Self;
27    fn with_user_agent(self) -> Self;
28
29    fn send_request(
30        self,
31        operation_name: &str,
32    ) -> impl std::future::Future<Output = Result<bytes::Bytes, BpiError>> + Send;
33
34    fn send_bpi_payload<T>(
35        self,
36        operation_name: &str,
37    ) -> impl std::future::Future<Output = Result<T, BpiError>> + Send
38    where
39        Self: Sized + Send,
40        T: DeserializeOwned;
41
42    fn send_bpi_optional_payload<T>(
43        self,
44        operation_name: &str,
45    ) -> impl std::future::Future<Output = Result<Option<T>, BpiError>> + Send
46    where
47        Self: Sized + Send,
48        T: DeserializeOwned;
49
50    fn log_url(self, operation_name: &str) -> Self;
51}
52
53impl BilibiliRequest for RequestBuilder {
54    /// UserAgent + Referer + Origin
55    fn with_bilibili_headers(self) -> Self {
56        self.with_user_agent()
57            .header("Referer", "https://www.bilibili.com/")
58            .header("Origin", "https://www.bilibili.com")
59    }
60
61    fn with_user_agent(self) -> Self {
62        self.header(
63            "User-Agent",
64            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
65        )
66    }
67
68    async fn send_request(self, operation_name: &str) -> Result<bytes::Bytes, BpiError> {
69        ReqwestTransport::send_request_builder(self, operation_name)
70            .await
71            .map(|response| response.body)
72    }
73
74    async fn send_bpi_payload<T>(self, operation_name: &str) -> Result<T, BpiError>
75    where
76        T: DeserializeOwned,
77    {
78        let start = Instant::now();
79        let response =
80            ReqwestTransport::send_request_builder(self.log_url(operation_name), operation_name)
81                .await?;
82        let result = decode_bpi_payload_response(operation_name, &response)?;
83
84        log_success(operation_name, start);
85        Ok(result)
86    }
87
88    async fn send_bpi_optional_payload<T>(self, operation_name: &str) -> Result<Option<T>, BpiError>
89    where
90        T: DeserializeOwned,
91    {
92        let start = Instant::now();
93        let response =
94            ReqwestTransport::send_request_builder(self.log_url(operation_name), operation_name)
95                .await?;
96        let result = decode_bpi_optional_payload_response(operation_name, &response)?;
97
98        log_success(operation_name, start);
99        Ok(result)
100    }
101
102    fn log_url(self, operation_name: &str) -> Self {
103        tracing::info!("开始请求 {}", operation_name);
104
105        self
106    }
107}
108
109#[cfg(feature = "manga")]
110pub(crate) async fn send_bpi_envelope<T>(
111    request: RequestBuilder,
112    operation_name: &str,
113) -> Result<ApiEnvelope<T>, BpiError>
114where
115    T: DeserializeOwned,
116{
117    let start = Instant::now();
118    let response =
119        ReqwestTransport::send_request_builder(request.log_url(operation_name), operation_name)
120            .await?;
121    let result = decode_bpi_envelope_response(operation_name, &response)?;
122
123    log_success(operation_name, start);
124    Ok(result)
125}
126
127#[cfg(feature = "manga")]
128fn decode_bpi_envelope_response<T>(
129    operation_name: &str,
130    response: &TransportResponse,
131) -> Result<ApiEnvelope<T>, BpiError>
132where
133    T: DeserializeOwned,
134{
135    decode_bpi_transport_response(operation_name, response, |decoded| {
136        decoded.into_api_envelope()
137    })
138}
139
140fn decode_bpi_payload_response<T>(
141    operation_name: &str,
142    response: &TransportResponse,
143) -> Result<T, BpiError>
144where
145    T: DeserializeOwned,
146{
147    decode_bpi_transport_response(operation_name, response, |decoded| {
148        decoded.into_payload().map(|payload| payload.payload)
149    })
150}
151
152fn decode_bpi_optional_payload_response<T>(
153    operation_name: &str,
154    response: &TransportResponse,
155) -> Result<Option<T>, BpiError>
156where
157    T: DeserializeOwned,
158{
159    decode_bpi_transport_response(operation_name, response, |decoded| {
160        decoded
161            .into_optional_payload()
162            .map(|payload| payload.payload)
163    })
164}
165
166fn decode_bpi_transport_response<T, R>(
167    operation_name: &str,
168    response: &TransportResponse,
169    extract: impl FnOnce(TransportEnvelope<T>) -> Result<R, BpiError>,
170) -> Result<R, BpiError>
171where
172    T: DeserializeOwned,
173{
174    match response.decode_api_envelope::<T>().and_then(extract) {
175        Ok(result) => Ok(result),
176        Err(err) => {
177            if let BpiError::Decode { source } = &err {
178                log_decode_error(operation_name, &response.body, source);
179            } else {
180                tracing::error!("{} API错误: {}", operation_name, err);
181            }
182            Err(err)
183        }
184    }
185}
186
187fn log_success(operation_name: &str, start: Instant) {
188    let duration = start.elapsed();
189    tracing::info!("{} 请求成功,耗时: {:.2?}", operation_name, duration);
190}
191
192fn log_decode_error(operation_name: &str, bytes: &[u8], error: &serde_json::Error) {
193    #[cfg(any(test, debug_assertions))]
194    {
195        let json_str = String::from_utf8_lossy(bytes);
196        let error_pos = error.column().saturating_sub(1);
197        let start = floor_char_boundary(&json_str, error_pos.saturating_sub(25));
198        let end = floor_char_boundary(&json_str, (error_pos + 25).min(json_str.len()));
199        let context = &json_str[start..end];
200        tracing::error!(
201            "{} JSON解析失败 (行:{} 列:{}): {}",
202            operation_name,
203            error.line(),
204            error.column(),
205            error
206        );
207        tracing::error!(
208            "错误位置: ...{}... ({}^)",
209            context,
210            " ".repeat(error_pos.saturating_sub(start))
211        );
212    }
213    #[cfg(not(any(test, debug_assertions)))]
214    {
215        let _ = bytes;
216        tracing::error!("{} JSON解析失败: {}", operation_name, error);
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use std::time::Duration;
223
224    use bytes::Bytes;
225    use serde::Deserialize;
226
227    use super::*;
228    use crate::transport::{ResponseMetadata, TransportResponse};
229    use crate::{BpiError, BpiResult};
230
231    #[derive(Debug, Deserialize, PartialEq, Eq)]
232    struct Payload {
233        value: u64,
234    }
235
236    #[test]
237    fn decode_bpi_payload_response_returns_required_payload() -> BpiResult<()> {
238        let payload = decode_bpi_payload_response::<Payload>(
239            "unit",
240            &response(br#"{ "code": 0, "data": { "value": 42 } }"#),
241        )?;
242
243        assert_eq!(payload.value, 42);
244        Ok(())
245    }
246
247    #[test]
248    fn decode_bpi_payload_response_rejects_missing_required_payload() {
249        let err = decode_bpi_payload_response::<Payload>(
250            "unit",
251            &response(br#"{ "code": 0, "message": "0" }"#),
252        )
253        .unwrap_err();
254
255        assert!(matches!(err, BpiError::MissingData));
256    }
257
258    #[test]
259    fn decode_bpi_optional_payload_response_allows_missing_payload() -> BpiResult<()> {
260        let payload = decode_bpi_optional_payload_response::<Payload>(
261            "unit",
262            &response(br#"{ "code": 0, "message": "0" }"#),
263        )?;
264
265        assert!(payload.is_none());
266        Ok(())
267    }
268
269    fn response(body: &'static [u8]) -> TransportResponse {
270        TransportResponse {
271            metadata: ResponseMetadata {
272                status: 200,
273                duration: Duration::from_millis(1),
274                api_code: None,
275            },
276            body: Bytes::from_static(body),
277        }
278    }
279}