Skip to main content

bpi_rs/
response.rs

1use crate::err::error::BpiError;
2use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned};
3
4/// Crate-wide result type for bpi-rs operations.
5pub type BpiResult<T> = Result<T, BpiError>;
6
7/// Canonical Bilibili JSON envelope used by most web API endpoints.
8#[derive(Debug, Serialize, Clone)]
9pub struct ApiEnvelope<T> {
10    /// API return code. `0` means success.
11    pub code: i32,
12
13    /// Payload returned by successful endpoints.
14    pub data: Option<T>,
15
16    /// API message. Bilibili often returns `"0"` for success.
17    pub message: String,
18
19    /// Optional status flag returned by some endpoints.
20    pub status: bool,
21}
22
23impl<'de, T> Deserialize<'de> for ApiEnvelope<T>
24where
25    T: Deserialize<'de>,
26{
27    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
28    where
29        D: Deserializer<'de>,
30    {
31        let raw = RawEnvelope::<T>::deserialize(deserializer)?;
32        Ok(Self {
33            code: raw.code.or(raw.errno).unwrap_or_default(),
34            data: raw.data,
35            message: raw.message.or(raw.msg).or(raw.show_msg).unwrap_or_default(),
36            status: raw.status,
37        })
38    }
39}
40
41impl<T> ApiEnvelope<T> {
42    /// Parses a JSON envelope from bytes.
43    pub fn from_slice(bytes: &[u8]) -> BpiResult<Self>
44    where
45        T: DeserializeOwned,
46    {
47        serde_json::from_slice(bytes).map_err(BpiError::from)
48    }
49
50    /// Returns this envelope if it represents a successful API response.
51    pub fn ensure_success(self) -> BpiResult<Self> {
52        if self.code == 0 {
53            return Ok(self);
54        }
55
56        if self.message.is_empty() || self.message == "0" {
57            Err(BpiError::from_code(self.code))
58        } else {
59            Err(BpiError::from_code_message(self.code, self.message))
60        }
61    }
62
63    /// Extracts a required payload from a successful response.
64    pub fn into_payload(self) -> BpiResult<T> {
65        self.ensure_success()?.data.ok_or(BpiError::MissingData)
66    }
67
68    /// Extracts an optional payload from a successful response.
69    pub fn into_optional_payload(self) -> BpiResult<Option<T>> {
70        Ok(self.ensure_success()?.data)
71    }
72
73    /// Extracts a required payload without checking the response code.
74    ///
75    /// This is kept for endpoints whose payload contains the business status,
76    /// such as QR polling.
77    pub fn into_data(self) -> Result<T, BpiError> {
78        self.data.ok_or(BpiError::missing_data())
79    }
80}
81
82/// Compatibility alias for modules that still expose full Bilibili envelopes.
83pub type BpiResponse<T> = ApiEnvelope<T>;
84
85#[derive(Debug, Deserialize)]
86#[serde(bound(deserialize = "T: Deserialize<'de>"))]
87struct RawEnvelope<T> {
88    #[serde(default)]
89    code: Option<i32>,
90    #[serde(default)]
91    errno: Option<i32>,
92    #[serde(default, alias = "result")]
93    data: Option<T>,
94    #[serde(default)]
95    message: Option<String>,
96    #[serde(default)]
97    msg: Option<String>,
98    #[serde(default, rename = "showMsg")]
99    show_msg: Option<String>,
100    #[serde(default)]
101    status: bool,
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[derive(Debug, Deserialize, PartialEq, Eq)]
109    struct FixturePayload {
110        title: String,
111        aid: u64,
112    }
113
114    fn fixture(name: &str) -> &'static [u8] {
115        match name {
116            "success" => include_bytes!("../tests/fixtures/envelope/success.json"),
117            "result-alias" => include_bytes!("../tests/fixtures/envelope/result-alias.json"),
118            "api-error" => include_bytes!("../tests/fixtures/envelope/api-error.json"),
119            "missing-data" => include_bytes!("../tests/fixtures/envelope/missing-data.json"),
120            "no-payload" => include_bytes!("../tests/fixtures/envelope/no-payload.json"),
121            _ => unreachable!("unknown fixture"),
122        }
123    }
124
125    #[test]
126    fn api_envelope_extracts_data_payload() -> Result<(), BpiError> {
127        let payload =
128            ApiEnvelope::<FixturePayload>::from_slice(fixture("success"))?.into_payload()?;
129
130        assert_eq!(payload.title, "fixture video");
131        Ok(())
132    }
133
134    #[test]
135    fn api_envelope_extracts_result_alias_payload() -> Result<(), BpiError> {
136        let payload =
137            ApiEnvelope::<FixturePayload>::from_slice(fixture("result-alias"))?.into_payload()?;
138
139        assert_eq!(payload.aid, 170002);
140        Ok(())
141    }
142
143    #[test]
144    fn api_envelope_returns_missing_data_for_required_payload() {
145        let err = ApiEnvelope::<FixturePayload>::from_slice(fixture("missing-data"))
146            .and_then(ApiEnvelope::into_payload)
147            .unwrap_err();
148
149        assert!(matches!(err, BpiError::MissingData));
150    }
151
152    #[test]
153    fn api_envelope_allows_optional_payload() -> Result<(), BpiError> {
154        let payload = ApiEnvelope::<FixturePayload>::from_slice(fixture("no-payload"))?
155            .into_optional_payload()?;
156
157        assert!(payload.is_none());
158        Ok(())
159    }
160
161    #[test]
162    fn api_envelope_converts_api_error() {
163        let err = ApiEnvelope::<FixturePayload>::from_slice(fixture("api-error"))
164            .and_then(ApiEnvelope::ensure_success)
165            .unwrap_err();
166
167        assert!(matches!(err, BpiError::Api { code: -101, .. }));
168        assert_eq!(err.code(), Some(-101));
169    }
170
171    #[test]
172    fn api_envelope_treats_null_message_as_empty() -> Result<(), BpiError> {
173        let payload = ApiEnvelope::<LoginCoinFixture>::from_slice(
174            br#"{ "code": 0, "message": null, "data": { "money": 0.0 } }"#,
175        )?
176        .into_payload()?;
177
178        assert_eq!(payload.money, 0.0);
179        Ok(())
180    }
181
182    #[test]
183    fn api_envelope_maps_errno_login_error() {
184        let err = ApiEnvelope::<serde_json::Value>::from_slice(
185            br#"{ "errno": 800501007, "msg": "user not login", "showMsg": "user not login" }"#,
186        )
187        .and_then(ApiEnvelope::ensure_success)
188        .unwrap_err();
189
190        assert!(err.requires_login());
191        assert_eq!(err.code(), Some(800501007));
192    }
193
194    #[test]
195    fn api_envelope_returns_decode_error_for_invalid_json() {
196        let err = ApiEnvelope::<FixturePayload>::from_slice(b"{not json").unwrap_err();
197
198        assert!(matches!(err, BpiError::Decode { .. }));
199    }
200
201    #[derive(Debug, Deserialize)]
202    struct LoginCoinFixture {
203        money: f64,
204    }
205}