web_push/clients/
request_builder.rs

1//! Functions used to send and consume push http messages.
2//! This module can be used to build custom clients.
3
4use http::{
5    header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE},
6    Request, StatusCode,
7};
8
9use crate::{
10    error::{ErrorInfo, WebPushError},
11    message::WebPushMessage,
12};
13
14/// Builds the request to send to the push service.
15///
16/// This function is generic over the request body, this means that you can swap out client implementations
17/// even if they use different body types.
18///
19/// # Example
20///
21/// ```no_run
22/// # use web_push::{SubscriptionInfo, WebPushMessageBuilder};
23/// # use web_push::request_builder::build_request;
24/// let info = SubscriptionInfo::new(
25///  "http://google.com",
26///  "BLMbF9ffKBiWQLCKvTHb6LO8Nb6dcUh6TItC455vu2kElga6PQvUmaFyCdykxY2nOSSL3yKgfbmFLRTUaGv4yV8",
27///  "xS03Fi5ErfTNH_l9WHE9Ig",
28///  );
29///
30///  let mut builder = WebPushMessageBuilder::new(&info);
31///
32///  // Build the request for isahc
33///  # #[cfg(feature = "isahc-client")]
34///  let request = build_request::<isahc::Body>(builder.build().unwrap());
35///  // Send using a http client
36/// ```
37pub fn build_request<T>(message: WebPushMessage) -> Request<T>
38where
39    T: From<Vec<u8>> + From<&'static str>, //This bound can be reduced to a &[u8] instead of str if needed
40{
41    let mut builder = Request::builder()
42        .method("POST")
43        .uri(message.endpoint)
44        .header("TTL", format!("{}", message.ttl).as_bytes());
45
46    if let Some(urgency) = message.urgency {
47        builder = builder.header("Urgency", urgency.to_string());
48    }
49
50    if let Some(topic) = message.topic {
51        builder = builder.header("Topic", topic);
52    }
53
54    if let Some(payload) = message.payload {
55        builder = builder
56            .header(CONTENT_ENCODING, payload.content_encoding.to_str())
57            .header(CONTENT_LENGTH, format!("{}", payload.content.len() as u64).as_bytes())
58            .header(CONTENT_TYPE, "application/octet-stream");
59
60        for (k, v) in payload.crypto_headers.into_iter() {
61            let v: &str = v.as_ref();
62            builder = builder.header(k, v);
63        }
64
65        builder.body(payload.content.into()).unwrap()
66    } else {
67        builder.body("".into()).unwrap()
68    }
69}
70
71/// Parses the response from the push service, and will return `Err` if the request was bad.
72pub fn parse_response(response_status: StatusCode, body: Vec<u8>) -> Result<(), WebPushError> {
73    if response_status.is_success() {
74        return Ok(());
75    }
76
77    let info: ErrorInfo = serde_json::from_slice(&body).unwrap_or_else(|_| ErrorInfo {
78        code: response_status.as_u16(),
79        errno: 999,
80        error: "unknown error".into(),
81        message: String::from_utf8(body).unwrap_or_else(|_| "-".into()),
82    });
83
84    match response_status {
85        StatusCode::UNAUTHORIZED => Err(WebPushError::Unauthorized(info)),
86        StatusCode::GONE => Err(WebPushError::EndpointNotValid(info)),
87        StatusCode::NOT_FOUND => Err(WebPushError::EndpointNotFound(info)),
88        StatusCode::PAYLOAD_TOO_LARGE => Err(WebPushError::PayloadTooLarge),
89        StatusCode::BAD_REQUEST => Err(WebPushError::BadRequest(info)),
90        status if status.is_server_error() => Err(WebPushError::ServerError {
91            retry_after: None,
92            info,
93        }),
94        _ => Err(WebPushError::Other(info)),
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use http::Uri;
101
102    use crate::{
103        clients::request_builder::*, error::WebPushError, http_ece::ContentEncoding, message::WebPushMessageBuilder,
104        Urgency,
105    };
106
107    #[cfg(feature = "isahc-client")]
108    #[test]
109    fn builds_a_correct_request_with_empty_payload() {
110        //This *was* a real token
111        let sub = serde_json::json!({"endpoint":"https://fcm.googleapis.com/fcm/send/eKClHsXFm9E:APA91bH2x3gNOMv4dF1lQfCgIfOet8EngqKCAUS5DncLOd5hzfSUxcjigIjw9ws-bqa-KmohqiTOcgepAIVO03N39dQfkEkopubML_m3fyvF03pV9_JCB7SxpUjcFmBSVhCaWS6m8l7x",
112            "expirationTime":null,
113            "keys":{"p256dh":
114                "BGa4N1PI79lboMR_YrwCiCsgp35DRvedt7opHcf0yM3iOBTSoQYqQLwWxAfRKE6tsDnReWmhsImkhDF_DBdkNSU",
115                "auth":"EvcWjEgzr4rbvhfi3yds0A"}
116        });
117
118        let info = serde_json::from_value(sub).unwrap();
119
120        let mut builder = WebPushMessageBuilder::new(&info);
121
122        builder.set_ttl(420);
123        builder.set_urgency(Urgency::VeryLow);
124        builder.set_topic("some-topic".into());
125
126        let request = build_request::<isahc::Body>(builder.build().unwrap());
127        let ttl = request.headers().get("TTL").unwrap().to_str().unwrap();
128        let urgency = request.headers().get("Urgency").unwrap().to_str().unwrap();
129        let topic = request.headers().get("Topic").unwrap().to_str().unwrap();
130        let expected_uri: Uri = "fcm.googleapis.com".parse().unwrap();
131
132        assert_eq!("420", ttl);
133        assert_eq!("very-low", urgency);
134        assert_eq!("some-topic", topic);
135        assert_eq!(expected_uri.host(), request.uri().host());
136    }
137
138    #[cfg(feature = "isahc-client")]
139    #[test]
140    fn builds_a_correct_request_with_payload() {
141        //This *was* a real token
142        let sub = serde_json::json!({"endpoint":"https://fcm.googleapis.com/fcm/send/eKClHsXFm9E:APA91bH2x3gNOMv4dF1lQfCgIfOet8EngqKCAUS5DncLOd5hzfSUxcjigIjw9ws-bqa-KmohqiTOcgepAIVO03N39dQfkEkopubML_m3fyvF03pV9_JCB7SxpUjcFmBSVhCaWS6m8l7x",
143            "expirationTime":null,
144            "keys":{"p256dh":
145                "BGa4N1PI79lboMR_YrwCiCsgp35DRvedt7opHcf0yM3iOBTSoQYqQLwWxAfRKE6tsDnReWmhsImkhDF_DBdkNSU",
146                "auth":"EvcWjEgzr4rbvhfi3yds0A"}
147        });
148
149        let info = serde_json::from_value(sub).unwrap();
150
151        let mut builder = WebPushMessageBuilder::new(&info);
152
153        builder.set_payload(ContentEncoding::Aes128Gcm, "test".as_bytes());
154
155        let request = build_request::<isahc::Body>(builder.build().unwrap());
156
157        let encoding = request.headers().get("Content-Encoding").unwrap().to_str().unwrap();
158
159        let length = request.headers().get("Content-Length").unwrap();
160        let expected_uri: Uri = "fcm.googleapis.com".parse().unwrap();
161
162        assert_eq!("230", length);
163        assert_eq!("aes128gcm", encoding);
164        assert_eq!(expected_uri.host(), request.uri().host());
165    }
166
167    #[test]
168    fn parses_a_successful_response_correctly() {
169        assert!(matches!(parse_response(StatusCode::OK, vec![]), Ok(())));
170    }
171
172    #[test]
173    fn parses_an_unauthorized_response_correctly() {
174        assert!(matches!(
175            parse_response(StatusCode::UNAUTHORIZED, vec![]),
176            Err(WebPushError::Unauthorized(_))
177        ));
178    }
179
180    #[test]
181    fn parses_a_gone_response_correctly() {
182        assert!(matches!(
183            parse_response(StatusCode::GONE, vec![]),
184            Err(WebPushError::EndpointNotValid(_))
185        ));
186    }
187
188    #[test]
189    fn parses_a_not_found_response_correctly() {
190        assert!(matches!(
191            parse_response(StatusCode::NOT_FOUND, vec![]),
192            Err(WebPushError::EndpointNotFound(_))
193        ));
194    }
195
196    #[test]
197    fn parses_a_payload_too_large_response_correctly() {
198        assert!(matches!(
199            parse_response(StatusCode::PAYLOAD_TOO_LARGE, vec![]),
200            Err(WebPushError::PayloadTooLarge)
201        ));
202    }
203
204    #[test]
205    fn parses_a_server_error_response_correctly() {
206        assert!(matches!(
207            parse_response(StatusCode::INTERNAL_SERVER_ERROR, vec![]),
208            Err(WebPushError::ServerError { .. })
209        ));
210    }
211
212    #[test]
213    fn parses_a_bad_request_response_with_no_body_correctly() {
214        assert!(matches!(
215            parse_response(StatusCode::BAD_REQUEST, vec![]),
216            Err(WebPushError::BadRequest(_))
217        ));
218    }
219
220    #[test]
221    fn parses_a_bad_request_response_with_body_correctly() {
222        let json = r#"
223            {
224                "code": 400,
225                "errno": 103,
226                "error": "FooBar",
227                "message": "No message found"
228            }
229        "#;
230
231        assert!(matches!(
232            parse_response(StatusCode::BAD_REQUEST, json.as_bytes().to_vec()),
233            Err(WebPushError::BadRequest(ErrorInfo {
234                code: 400,
235                errno: 103,
236                error: _,
237                message: _,
238            })),
239        ));
240    }
241}