Skip to main content

bpi_rs/
request.rs

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