oasis_client/
gateway.rs

1use std::str::FromStr as _;
2
3use anyhow::{anyhow, Result};
4use http::header::{HeaderMap, HeaderName, HeaderValue};
5use oasis_types::{Address, RpcError};
6use uuid::Uuid;
7
8#[cfg(not(target_env = "sgx"))]
9use reqwest::Client;
10
11use crate::api::*;
12
13pub trait Gateway {
14    /// Deploys a new service with the provided initcode.
15    /// `initcode` is expected to be the Wasm bytecode concatenated with the the constructor stdin.
16    /// Upon success, returns the address of the new service.
17    fn deploy(&self, initcode: &[u8]) -> Result<Address, RpcError>;
18
19    /// Returns the output of calling the service at `address` with `data` as stdin.
20    fn rpc(&self, address: Address, payload: &[u8]) -> Result<Vec<u8>, RpcError>;
21}
22
23/// Holds necessary information to make http requests to the gateway.
24///
25/// # Example
26///
27/// ```no_run
28/// use oasis_client::Gateway as _;
29///
30/// let url = "https://gateway.devnet.oasiscloud.io";
31/// let api_key = "AAACL7PMQhh3/rxLr9KJpsAJhz5zBlpAB73uwgAt/6BQ4+Bw";
32/// let gateway = oasis_client::HttpGatewayBuilder::new(url)
33///     .api_key(api_key)
34///     .build();
35/// let address = gateway.deploy(b"service Wasm bytecode").unwrap();
36/// let response = gateway.rpc(address, b"data").unwrap();
37/// ```
38pub struct HttpGateway {
39    /// The url of the gateway.
40    url: String,
41
42    /// The http headers to include in all requests sent to the gateway.
43    headers: HeaderMap,
44
45    /// The http session used for sending http requests.
46    client: Client,
47
48    /// A polling service used to receive responses from asynchronous requests made.
49    polling_params: PollingParams,
50}
51
52#[derive(Clone, Debug)]
53pub struct HttpGatewayBuilder {
54    url: String,
55    api_key: Option<String>,
56    headers: HeaderMap,
57    polling_params: PollingParams,
58}
59
60impl HttpGatewayBuilder {
61    pub fn new(url: impl AsRef<str>) -> Self {
62        Self {
63            url: url.as_ref().to_string(),
64            ..Default::default()
65        }
66    }
67
68    /// Set the api key expected by the Oasis Developer gateway.
69    pub fn api_key(mut self, api_key: impl AsRef<str>) -> Self {
70        self.api_key = Some(api_key.as_ref().to_string());
71        self
72    }
73
74    /// Append the value of the named header.
75    pub fn header(mut self, name: impl AsRef<[u8]>, value: impl AsRef<[u8]>) -> Result<Self> {
76        self.headers.insert(
77            HeaderName::from_bytes(name.as_ref())?,
78            HeaderValue::from_bytes(value.as_ref())?,
79        );
80        Ok(self)
81    }
82
83    /// Assign the provided headers as the defaults for all requests made by the `HttpGateway`.
84    pub fn headers(mut self, headers: HeaderMap) -> Self {
85        self.headers = headers;
86        self
87    }
88
89    /// Set the polling parameters for the `HttpGateway`.
90    pub fn polling_params(mut self, params: PollingParams) -> Self {
91        self.polling_params = params;
92        self
93    }
94
95    /// Creates a new `HttpGateway` instance that is configured with headers expected by the
96    /// Oasis Developer gateway.
97    pub fn build(self) -> HttpGateway {
98        let session_key = Uuid::new_v4().to_string();
99
100        let mut headers = self.headers;
101        headers.insert("X-OASIS-INSECURE-AUTH", HeaderValue::from_static("1"));
102        if let Some(api_key) = self.api_key {
103            headers.insert(
104                "X-OASIS-LOGIN-TOKEN",
105                HeaderValue::from_str(&api_key).unwrap(),
106            );
107        }
108        headers.insert(
109            "X-OASIS-SESSION-KEY",
110            HeaderValue::from_str(&session_key).unwrap(),
111        );
112
113        HttpGateway::new(self.url, headers, self.polling_params)
114    }
115}
116
117impl Default for HttpGatewayBuilder {
118    fn default() -> Self {
119        Self {
120            url: "https://gateway.devnet.oasiscloud.io".to_string(),
121            api_key: None,
122            headers: HeaderMap::new(),
123            polling_params: PollingParams::default(),
124        }
125    }
126}
127
128#[derive(Clone, Copy, Debug)]
129pub struct PollingParams {
130    /// Interval between sending requests in milliseconds.
131    pub sleep_duration: u64,
132
133    /// Number of attempts before giving up with an error.
134    pub max_attempts: u32,
135}
136
137impl Default for PollingParams {
138    fn default() -> Self {
139        Self {
140            sleep_duration: 500,
141            max_attempts: 20,
142        }
143    }
144}
145
146impl HttpGateway {
147    /// Creates a new `HttpGateway` pointed at `url` and with default `headers`.
148    pub fn new(url: String, headers: HeaderMap, polling_params: PollingParams) -> Self {
149        Self {
150            url,
151            headers,
152            client: Client::new(),
153            polling_params,
154        }
155    }
156
157    /// Submit given request asynchronously and poll for results.
158    fn post_and_poll(&self, api: DeveloperGatewayApi, body: GatewayRequest) -> Result<Event> {
159        let response: AsyncResponse = self.request(api.method, api.url, body)?;
160
161        match self.poll_for_response(response.id)? {
162            Event::Error { description, .. } => Err(anyhow!("{}", description)),
163            e => Ok(e),
164        }
165    }
166
167    /// Synchronous polling. Repeatedly attempts to retrieve the event of the given
168    /// request id. If polling fails `max_attempts` times an error is returned.
169    fn poll_for_response(&self, request_id: u64) -> Result<Event> {
170        let PollingParams {
171            sleep_duration,
172            max_attempts,
173        } = self.polling_params;
174
175        let poll_request = GatewayRequest::Poll {
176            offset: request_id,
177            count: 1, // poll for a single event
178            discard_previous: true,
179        };
180
181        for attempt in 0..max_attempts {
182            let events: PollEventResponse = self.request(
183                SERVICE_POLL_API.method,
184                &SERVICE_POLL_API.url,
185                &poll_request,
186            )?;
187
188            // we polled for a singe event so we want the first event in the list, if it exists.
189            let event = events.events.first();
190            if let Some(e) = event {
191                return Ok(e.clone());
192            }
193
194            info!(
195                "polling... (request id: {}, attempt: {})",
196                request_id, attempt
197            );
198
199            #[cfg(not(target_env = "sgx"))]
200            std::thread::sleep(std::time::Duration::from_millis(sleep_duration));
201
202            // `sleep` is not supported in EDP. Spin wait instead.
203            #[cfg(target_env = "sgx")]
204            {
205                let start = std::time::Instant::now();
206                let duration = std::time::Duration::from_millis(sleep_duration);
207                while start.elapsed() < duration {
208                    std::thread::yield_now();
209                }
210            }
211        }
212        Err(anyhow!("Exceeded max polling attempts"))
213    }
214
215    /// Submits a request to the gateway. The body of the request is json-serialized and the
216    /// response is expected to be json-serialized as well.
217    fn request<P: serde::Serialize, Q: serde::de::DeserializeOwned>(
218        &self,
219        method: RequestMethod,
220        url: &str,
221        payload: P,
222    ) -> Result<Q> {
223        let url = if self.url.ends_with('/') {
224            format!("{}{}", self.url, url)
225        } else {
226            format!("{}/{}", self.url, url)
227        };
228        let builder = match method {
229            RequestMethod::GET => self.client.get(&url),
230            RequestMethod::POST => self.client.post(&url),
231        };
232
233        let mut res = builder
234            .headers(self.headers.clone())
235            .json(&payload)
236            .send()?;
237        if res.status().is_success() {
238            Ok(res.json()?)
239        } else {
240            Err(anyhow!("gateway returned error: {}", res.status()))
241        }
242    }
243}
244
245impl Gateway for HttpGateway {
246    fn deploy(&self, initcode: &[u8]) -> std::result::Result<Address, RpcError> {
247        let initcode_hex = hex::encode(initcode);
248        info!("deploying service `{}`", &initcode_hex[..32]);
249
250        let body = GatewayRequest::Deploy {
251            data: format!("0x{}", initcode_hex),
252        };
253
254        self.post_and_poll(SERVICE_DEPLOY_API, body)
255            .and_then(|event| {
256                match event {
257                    Event::DeployService { address, .. } => {
258                        Ok(Address::from_str(&address[2..] /* strip 0x */)?)
259                    }
260                    e => Err(anyhow!("expecting `DeployService` event. got {:?}", e)),
261                }
262            })
263            .map_err(RpcError::Gateway)
264    }
265
266    fn rpc(&self, address: Address, payload: &[u8]) -> std::result::Result<Vec<u8>, RpcError> {
267        info!("making RPC to {}", address);
268
269        let body = GatewayRequest::Execute {
270            address: address.to_string(),
271            data: format!("0x{}", hex::encode(payload)),
272        };
273
274        self.post_and_poll(SERVICE_EXECUTE_API, body)
275            .and_then(|event| match event {
276                Event::ExecuteService { output, .. } => Ok(hex::decode(&output[2..])?),
277                e => Err(anyhow!("expecting `ExecuteService` event. got {:?}", e)),
278            })
279            .map_err(RpcError::Gateway)
280    }
281}
282
283#[cfg(all(test, not(target_env = "sgx")))]
284mod tests {
285    use super::*;
286
287    use mockito::mock;
288    use serde_json::json;
289
290    // The following are randomly sampled from allowable strings.
291    const API_KEY: &str = "AAACL7PMQhh3/rxLr9KJpsAJhz5zBlpAB73uwgAt/6BQ4+Bw";
292    const PAYLOAD_HEX: &str = "0x144c6bda090723de712e52b92b4c758d78348ddce9aa80ca8ef51125bfb308";
293    const FIXTURE_ADDR: &str = "0xb8b3666d8fea887d97ab54f571b8e5020c5c8b58";
294
295    #[test]
296    fn test_deploy() {
297        let fixture_addr = Address::from_str(&FIXTURE_ADDR[2..]).unwrap();
298        let poll_id = 42;
299
300        let _m_deploy = mock("POST", "/v0/api/service/deploy")
301            .match_header("content-type", "application/json")
302            .match_header("x-oasis-login-token", API_KEY)
303            .match_body(mockito::Matcher::Json(json!({ "data": PAYLOAD_HEX })))
304            .with_header("content-type", "text/json")
305            .with_body(json!({ "id": poll_id }).to_string())
306            .create();
307
308        let _m_poll = mock("POST", "/v0/api/service/poll")
309            .match_header("content-type", "application/json")
310            .match_header("x-oasis-login-token", API_KEY)
311            .match_body(mockito::Matcher::Json(json!({
312                "offset": poll_id,
313                "count": 1,
314                "discard_previous": true,
315            })))
316            .with_header("content-type", "text/json")
317            .with_body(
318                json!({
319                    "offset": poll_id,
320                    "events": [
321                        { "id": poll_id, "address": FIXTURE_ADDR }
322                    ]
323                })
324                .to_string(),
325            )
326            .create();
327
328        let gateway = HttpGatewayBuilder::new(mockito::server_url())
329            .api_key(API_KEY)
330            .build();
331        let addr = gateway
332            .deploy(&hex::decode(&PAYLOAD_HEX[2..]).unwrap())
333            .unwrap();
334
335        assert_eq!(addr, fixture_addr);
336    }
337
338    #[test]
339    fn test_rpc() {
340        let fixture_addr = Address::from_str(&FIXTURE_ADDR[2..]).unwrap();
341        let poll_id = 42;
342        let expected_output = "hello, client!";
343        let hex_output = "0x".to_string() + &hex::encode(expected_output.as_bytes());
344
345        let _m_execute = mock("POST", "/v0/api/service/execute")
346            .match_header("content-type", "application/json")
347            .match_header("x-oasis-login-token", mockito::Matcher::Missing)
348            .match_body(mockito::Matcher::Json(json!({
349                "address": FIXTURE_ADDR,
350                "data": PAYLOAD_HEX,
351            })))
352            .with_header("content-type", "text/json")
353            .with_body(json!({ "id": poll_id }).to_string())
354            .create();
355
356        let _m_poll = mock("POST", "/v0/api/service/poll")
357            .match_header("content-type", "application/json")
358            .match_header("x-oasis-login-token", mockito::Matcher::Missing)
359            .match_body(mockito::Matcher::Json(json!({
360                "offset": poll_id,
361                "count": 1,
362                "discard_previous": true,
363            })))
364            .with_header("content-type", "text/json")
365            .with_body(
366                json!({
367                    "offset": poll_id,
368                    "events": [
369                        { "id": poll_id, "address": FIXTURE_ADDR, "output": hex_output }
370                    ]
371                })
372                .to_string(),
373            )
374            .create();
375
376        let gateway = HttpGatewayBuilder::new(mockito::server_url()).build();
377        let output = gateway
378            .rpc(fixture_addr, &hex::decode(&PAYLOAD_HEX[2..]).unwrap())
379            .unwrap();
380
381        assert_eq!(output, expected_output.as_bytes());
382    }
383
384    #[test]
385    fn test_error() {
386        let fixture_addr = Address::from_str(&FIXTURE_ADDR[2..]).unwrap();
387        let poll_id = 42;
388        let err_code = 99;
389        let err_msg = "error!";
390
391        let _m_execute = mock("POST", "/v0/api/service/execute")
392            .match_header("content-type", "application/json")
393            .match_body(mockito::Matcher::Json(json!({
394                "address": FIXTURE_ADDR,
395                "data": PAYLOAD_HEX,
396            })))
397            .with_header("content-type", "text/json")
398            .with_body(json!({ "id": poll_id }).to_string())
399            .create();
400
401        let _m_poll = mock("POST", "/v0/api/service/poll")
402            .match_header("content-type", "application/json")
403            .match_body(mockito::Matcher::Json(json!({
404                "offset": poll_id,
405                "count": 1,
406                "discard_previous": true,
407            })))
408            .with_header("content-type", "text/json")
409            .with_body(
410                json!({
411                    "offset": poll_id,
412                    "events": [
413                        { "id": poll_id, "error_code": err_code, "description": err_msg }
414                    ]
415                })
416                .to_string(),
417            )
418            .create();
419
420        let gateway = HttpGatewayBuilder::new(mockito::server_url())
421            .api_key(API_KEY)
422            .build();
423        let err_output = gateway
424            .rpc(fixture_addr, &hex::decode(&PAYLOAD_HEX[2..]).unwrap())
425            .unwrap_err();
426
427        assert!(err_output.to_string().contains(err_msg))
428    }
429}