Skip to content

Commit

Permalink
Generate a Client method for Dropshot websocket channels
Browse files Browse the repository at this point in the history
rel: oxidecomputer/dropshot#403

Generated methods return a `WebsocketReactants` type that
at present can only be unwrapped into its inner `http::Request`
and `tokio::net::TcpStream` for the purpose of implementing
against the raw websocket connection, but may later be extended
as a generic to allow higher-level channel message definitions
(rel: oxidecomputer/dropshot#429)

The returned `Request` is an HTTP request containing the client's
part of the handshake for establishing a Websocket, and the `TcpStream`
is a raw TCP (non-Web-) socket. The consumer of the raw
`into_request_and_tcp_stream` interface is expected to send the
HTTP request over the TCP socket, i.e. by providing them to a websocket
implementation such as
`tokio_tungstenite::client_async(Request, TcpStream)`.
  • Loading branch information
lif committed Aug 31, 2022
1 parent 5aecfd0 commit be93e43
Show file tree
Hide file tree
Showing 23 changed files with 1,219 additions and 70 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Expand Up @@ -20,6 +20,7 @@ https://github.com/oxidecomputer/progenitor/compare/v0.1.1\...HEAD[Full list of
* Derive `Debug` for `Client` and builders for the various operations (#145)
* Builders for `struct` types (#171)
* Add a prelude that include the `Client` and any extension traits (#176)
* Added `Error::IoError` variant for channel connection failures (breaks `match` exhaustivity)

== 0.1.1 (released 2022-05-13)

Expand Down
42 changes: 42 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions progenitor-client/Cargo.toml
Expand Up @@ -9,8 +9,16 @@ description = "An OpenAPI client generator - client support"
[dependencies]
bytes = "1.2.1"
futures-core = "0.3.23"
http = "0.2"
percent-encoding = "2.1"
reqwest = { version = "0.11", default-features = false, features = ["json", "stream"] }
serde = "1.0"
serde_json = "1.0"
serde_urlencoded = "0.7.1"

# deps for websocket support
base64 = "0.13"
rand = "0.8"
# reqwest wraps hyper, but does not expose a read/write socket for us to work with,
# so we must make direct socket connections.
tokio = { version = "1.0", features = ["net"] }
82 changes: 82 additions & 0 deletions progenitor-client/src/progenitor_client.rs
Expand Up @@ -5,6 +5,7 @@
//! Support code for generated clients.

use std::ops::{Deref, DerefMut};
use std::str::FromStr;

use bytes::Bytes;
use futures_core::Stream;
Expand All @@ -14,6 +15,9 @@ use serde::{de::DeserializeOwned, Serialize};
type InnerByteStream =
std::pin::Pin<Box<dyn Stream<Item = reqwest::Result<Bytes>> + Send + Sync>>;

// used for Websockets requests
type HttpBareRequest = http::Request<()>;

/// Untyped byte stream used for both success and error responses.
pub struct ByteStream(InnerByteStream);

Expand Down Expand Up @@ -195,6 +199,65 @@ impl<T: std::fmt::Debug> std::fmt::Debug for ResponseValue<T> {
}
}

/// Value returned by generated client methods for Websocket channels.
///
/// Currently, the only interface available for Dropshot websocket channels is
/// providing their raw, unmodeled form (that is, Dropshot does not yet define
/// higher-level constructs for modeling the structure of Websocket messages
/// themselves).
///
/// The user is responsible for passing it to a websocket implementation, i.e.:
/// ```ignore
/// let (request, tcp_stream) = my_progenitor_client
/// .my_websocket_channel()
/// .answer(42)
/// .send()
/// .await?
/// .into_request_and_tcp_stream();
/// let ws_client = tokio_tungstenite::client_async(request, tcp_stream).await?;
/// ```
///
/// (As no request has been *made*, returning a [ResponseValue] would be inappropriate.)
pub struct WebsocketReactants {
request: HttpBareRequest,
tcp_stream: tokio::net::TcpStream,
}

impl WebsocketReactants {
pub fn new<T>(
rqw: reqwest::Request,
tcp_stream: tokio::net::TcpStream,
) -> Result<Self, Error<T>> {
// rebuild as http::Request, which tungstenite re-exports as its
// "IntoClientRequest" type.
// FIXME: this is obviously a hack, the better thing to do would be to
// implement using http::Request::builder() in the proc macro
let mut rb = http::Request::builder()
.method(rqw.method())
.version(rqw.version())
.uri(
http::Uri::from_str(rqw.url().as_str())
.map_err(|e| Error::InvalidRequest(format!("{:?}", e)))?,
);
for (k, v) in rqw.headers().iter() {
rb = rb.header(k, v);
}
let request = rb
.body(())
.map_err(|e| Error::InvalidRequest(e.to_string()))?;
Ok(Self {
request,
tcp_stream,
})
}

pub fn into_request_and_tcp_stream(
self,
) -> (HttpBareRequest, tokio::net::TcpStream) {
(self.request, self.tcp_stream)
}
}

/// Error produced by generated client methods.
///
/// The type parameter may be a struct if there's a single expected error type
Expand All @@ -207,6 +270,9 @@ pub enum Error<E = ()> {
/// A server error either due to the data, or with the connection.
CommunicationError(reqwest::Error),

/// A fundamental input/output error has occurred (e.g. unable to make a socket connection)
IoError(std::io::Error),

/// A documented, expected error response.
ErrorResponse(ResponseValue<E>),

Expand All @@ -225,6 +291,7 @@ impl<E> Error<E> {
match self {
Error::InvalidRequest(_) => None,
Error::CommunicationError(e) => e.status(),
Error::IoError(_) => None,
Error::ErrorResponse(rv) => Some(rv.status()),
Error::InvalidResponsePayload(e) => e.status(),
Error::UnexpectedResponse(r) => Some(r.status()),
Expand All @@ -239,6 +306,7 @@ impl<E> Error<E> {
match self {
Error::InvalidRequest(s) => Error::InvalidRequest(s),
Error::CommunicationError(e) => Error::CommunicationError(e),
Error::IoError(e) => Error::IoError(e),
Error::ErrorResponse(ResponseValue {
inner: _,
status,
Expand All @@ -262,6 +330,12 @@ impl<E> From<reqwest::Error> for Error<E> {
}
}

impl<E> From<std::io::Error> for Error<E> {
fn from(e: std::io::Error) -> Self {
Self::IoError(e)
}
}

impl<E> std::fmt::Display for Error<E>
where
ResponseValue<E>: ErrorFormat,
Expand All @@ -274,6 +348,9 @@ where
Error::CommunicationError(e) => {
write!(f, "Communication Error: {}", e)
}
Error::IoError(e) => {
write!(f, "Input/Output Error: {}", e)
}
Error::ErrorResponse(rve) => {
write!(f, "Error Response: ")?;
rve.fmt_info(f)
Expand Down Expand Up @@ -377,3 +454,8 @@ impl<E> RequestBuilderExt<E> for RequestBuilder {
})?))
}
}

#[doc(hidden)]
pub fn generate_websocket_key() -> String {
base64::encode(rand::random::<[u8; 16]>())
}
2 changes: 1 addition & 1 deletion progenitor-impl/Cargo.toml
Expand Up @@ -23,7 +23,7 @@ thiserror = "1.0"
# To publish, use a numbered version
#typify = "0.0.9"
typify = { git = "https://github.com/oxidecomputer/typify" }
unicode-ident = "1.0.3"
unicode-ident = "1.0.2"

[dev-dependencies]
dropshot = { git = "https://github.com/oxidecomputer/dropshot", default-features = false }
Expand Down
16 changes: 15 additions & 1 deletion progenitor-impl/src/lib.rs
Expand Up @@ -26,6 +26,8 @@ pub enum Error {
UnexpectedFormat(String),
#[error("invalid operation path {0}")]
InvalidPath(String),
#[error("invalid dropshot extension use: {0}")]
InvalidExtension(String),
#[error("internal error {0}")]
InternalError(String),
}
Expand Down Expand Up @@ -213,7 +215,13 @@ impl Generator {
let file = quote! {
// Re-export ResponseValue and Error since those are used by the
// public interface of Client.
pub use progenitor_client::{ByteStream, Error, ResponseValue};
pub use progenitor_client::{
ByteStream,
Error,
ResponseValue,
WebsocketReactants,
generate_websocket_key,
};
#[allow(unused_imports)]
use progenitor_client::{encode_path, RequestBuilderExt};

Expand Down Expand Up @@ -319,10 +327,12 @@ impl Generator {
#[allow(unused_imports)]
use super::{
encode_path,
generate_websocket_key,
ByteStream,
Error,
RequestBuilderExt,
ResponseValue,
WebsocketReactants,
};
#[allow(unused_imports)]
use std::convert::TryInto;
Expand Down Expand Up @@ -358,11 +368,15 @@ impl Generator {
#[allow(unused_imports)]
use super::{
encode_path,
generate_websocket_key,
ByteStream,
Error,
RequestBuilderExt,
ResponseValue,
WebsocketReactants,
};
#[allow(unused_imports)]
use std::convert::TryInto;

#(#builder_struct)*

Expand Down

0 comments on commit be93e43

Please sign in to comment.