chore(tonic-web): include crate in top-level workspace (#648)

This PR adds the tonic-web crate to tonic's workspace members. 
Unit and integration tests should now run as part of CI runs.
This commit is contained in:
Juan Alvarez
2021-06-03 11:48:53 -05:00
committed by GitHub
parent 3e95553c57
commit 8084f4ea26
21 changed files with 28 additions and 425 deletions
+305
View File
@@ -0,0 +1,305 @@
use std::error::Error;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::{Buf, BufMut, Bytes, BytesMut};
use futures_core::{ready, Stream};
use http::{header, HeaderMap, HeaderValue};
use http_body::{Body, SizeHint};
use pin_project::pin_project;
use tonic::Status;
use self::content_types::*;
pub(crate) mod content_types {
use http::{header::CONTENT_TYPE, HeaderMap};
pub(crate) const GRPC_WEB: &str = "application/grpc-web";
pub(crate) const GRPC_WEB_PROTO: &str = "application/grpc-web+proto";
pub(crate) const GRPC_WEB_TEXT: &str = "application/grpc-web-text";
pub(crate) const GRPC_WEB_TEXT_PROTO: &str = "application/grpc-web-text+proto";
pub(crate) fn is_grpc_web(headers: &HeaderMap) -> bool {
matches!(
content_type(headers),
Some(GRPC_WEB) | Some(GRPC_WEB_PROTO) | Some(GRPC_WEB_TEXT) | Some(GRPC_WEB_TEXT_PROTO)
)
}
fn content_type(headers: &HeaderMap) -> Option<&str> {
headers.get(CONTENT_TYPE).and_then(|val| val.to_str().ok())
}
}
const BUFFER_SIZE: usize = 8 * 1024;
const FRAME_HEADER_SIZE: usize = 5;
// 8th (MSB) bit of the 1st gRPC frame byte
// denotes an uncompressed trailer (as part of the body)
const GRPC_WEB_TRAILERS_BIT: u8 = 0b10000000;
#[derive(Copy, Clone, PartialEq, Debug)]
enum Direction {
Request,
Response,
}
#[derive(Copy, Clone, PartialEq, Debug)]
pub(crate) enum Encoding {
Base64,
None,
}
#[pin_project]
pub(crate) struct GrpcWebCall<B> {
#[pin]
inner: B,
buf: BytesMut,
direction: Direction,
encoding: Encoding,
poll_trailers: bool,
}
impl<B> GrpcWebCall<B> {
pub(crate) fn request(inner: B, encoding: Encoding) -> Self {
Self::new(inner, Direction::Request, encoding)
}
pub(crate) fn response(inner: B, encoding: Encoding) -> Self {
Self::new(inner, Direction::Response, encoding)
}
fn new(inner: B, direction: Direction, encoding: Encoding) -> Self {
GrpcWebCall {
inner,
buf: BytesMut::with_capacity(match (direction, encoding) {
(Direction::Response, Encoding::Base64) => BUFFER_SIZE,
_ => 0,
}),
direction,
encoding,
poll_trailers: true,
}
}
// This is to avoid passing a slice of bytes with a length that the base64
// decoder would consider invalid.
#[inline]
fn max_decodable(&self) -> usize {
(self.buf.len() / 4) * 4
}
fn decode_chunk(mut self: Pin<&mut Self>) -> Result<Option<Bytes>, Status> {
// not enough bytes to decode
if self.buf.is_empty() || self.buf.len() < 4 {
return Ok(None);
}
// Split `buf` at the largest index that is multiple of 4. Decode the
// returned `Bytes`, keeping the rest for the next attempt to decode.
let index = self.max_decodable();
base64::decode(self.as_mut().project().buf.split_to(index))
.map(|decoded| Some(Bytes::from(decoded)))
.map_err(internal_error)
}
}
impl<B> GrpcWebCall<B>
where
B: Body<Data = Bytes>,
B::Error: Error,
{
fn poll_decode(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<B::Data, Status>>> {
match self.encoding {
Encoding::Base64 => loop {
if let Some(bytes) = self.as_mut().decode_chunk()? {
return Poll::Ready(Some(Ok(bytes)));
}
let mut this = self.as_mut().project();
match ready!(this.inner.as_mut().poll_data(cx)) {
Some(Ok(data)) => this.buf.put(data),
Some(Err(e)) => return Poll::Ready(Some(Err(internal_error(e)))),
None => {
return if this.buf.has_remaining() {
Poll::Ready(Some(Err(internal_error("malformed base64 request"))))
} else {
Poll::Ready(None)
}
}
}
},
Encoding::None => match ready!(self.project().inner.poll_data(cx)) {
Some(res) => Poll::Ready(Some(res.map_err(internal_error))),
None => Poll::Ready(None),
},
}
}
fn poll_encode(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<B::Data, Status>>> {
let mut this = self.as_mut().project();
if let Some(mut res) = ready!(this.inner.as_mut().poll_data(cx)) {
if *this.encoding == Encoding::Base64 {
res = res.map(|b| base64::encode(b).into())
}
return Poll::Ready(Some(res.map_err(internal_error)));
}
// this flag is needed because the inner stream never
// returns Poll::Ready(None) when polled for trailers
if *this.poll_trailers {
return match ready!(this.inner.poll_trailers(cx)) {
Ok(Some(map)) => {
let mut frame = make_trailers_frame(map);
if *this.encoding == Encoding::Base64 {
frame = base64::encode(frame).into_bytes();
}
*this.poll_trailers = false;
Poll::Ready(Some(Ok(frame.into())))
}
Ok(None) => Poll::Ready(None),
Err(e) => Poll::Ready(Some(Err(internal_error(e)))),
};
}
Poll::Ready(None)
}
}
impl<B> Body for GrpcWebCall<B>
where
B: Body<Data = Bytes>,
B::Error: Error,
{
type Data = Bytes;
type Error = Status;
fn poll_data(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Self::Data, Self::Error>>> {
match self.direction {
Direction::Request => self.poll_decode(cx),
Direction::Response => self.poll_encode(cx),
}
}
fn poll_trailers(
self: Pin<&mut Self>,
_: &mut Context<'_>,
) -> Poll<Result<Option<HeaderMap<HeaderValue>>, Self::Error>> {
Poll::Ready(Ok(None))
}
fn is_end_stream(&self) -> bool {
self.inner.is_end_stream()
}
fn size_hint(&self) -> SizeHint {
self.inner.size_hint()
}
}
impl<B> Stream for GrpcWebCall<B>
where
B: Body<Data = Bytes>,
B::Error: Error,
{
type Item = Result<Bytes, Status>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
Body::poll_data(self, cx)
}
}
impl Encoding {
pub(crate) fn from_content_type(headers: &HeaderMap) -> Encoding {
Self::from_header(headers.get(header::CONTENT_TYPE))
}
pub(crate) fn from_accept(headers: &HeaderMap) -> Encoding {
Self::from_header(headers.get(header::ACCEPT))
}
pub(crate) fn to_content_type(&self) -> &'static str {
match self {
Encoding::Base64 => GRPC_WEB_TEXT_PROTO,
Encoding::None => GRPC_WEB_PROTO,
}
}
fn from_header(value: Option<&HeaderValue>) -> Encoding {
match value.and_then(|val| val.to_str().ok()) {
Some(GRPC_WEB_TEXT_PROTO) | Some(GRPC_WEB_TEXT) => Encoding::Base64,
_ => Encoding::None,
}
}
}
fn internal_error(e: impl std::fmt::Display) -> Status {
Status::internal(format!("tonic-web: {}", e))
}
// Key-value pairs encoded as a HTTP/1 headers block (without the terminating newline)
fn encode_trailers(trailers: HeaderMap) -> Vec<u8> {
trailers.iter().fold(Vec::new(), |mut acc, (key, value)| {
acc.put_slice(key.as_ref());
acc.push(b':');
acc.put_slice(value.as_bytes());
acc.put_slice(b"\r\n");
acc
})
}
fn make_trailers_frame(trailers: HeaderMap) -> Vec<u8> {
let trailers = encode_trailers(trailers);
let len = trailers.len();
assert!(len <= u32::MAX as usize);
let mut frame = Vec::with_capacity(len + FRAME_HEADER_SIZE);
frame.push(GRPC_WEB_TRAILERS_BIT);
frame.put_u32(len as u32);
frame.extend(trailers);
frame
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encoding_constructors() {
let cases = &[
(GRPC_WEB, Encoding::None),
(GRPC_WEB_PROTO, Encoding::None),
(GRPC_WEB_TEXT, Encoding::Base64),
(GRPC_WEB_TEXT_PROTO, Encoding::Base64),
("foo", Encoding::None),
];
let mut headers = HeaderMap::new();
for case in cases {
headers.insert(header::CONTENT_TYPE, case.0.parse().unwrap());
headers.insert(header::ACCEPT, case.0.parse().unwrap());
assert_eq!(Encoding::from_content_type(&headers), case.1, "{}", case.0);
assert_eq!(Encoding::from_accept(&headers), case.1, "{}", case.0);
}
}
}
+168
View File
@@ -0,0 +1,168 @@
use std::collections::{BTreeSet, HashSet};
use std::convert::TryFrom;
use std::time::Duration;
use http::{header::HeaderName, HeaderValue};
use tonic::body::BoxBody;
use tonic::transport::NamedService;
use tower_service::Service;
use crate::service::GrpcWeb;
use crate::BoxError;
const DEFAULT_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
const DEFAULT_EXPOSED_HEADERS: [&str; 2] = ["grpc-status", "grpc-message"];
/// A Configuration builder for grpc_web services.
///
/// `Config` can be used to tweak the behavior of tonic_web services. Currently,
/// `Config` instances only expose cors settings. However, since tonic_web is designed to work
/// with grpc-web compliant clients only, some cors options have specific default values and not
/// all settings are configurable.
///
/// ## Default values and configuration options
///
/// * `allow-origin`: All origins allowed by default. Configurable, but null and wildcard origins
/// are not supported.
/// * `allow-methods`: `[POST,OPTIONS]`. Not configurable.
/// * `allow-headers`: Set to whatever the `OPTIONS` request carries. Not configurable.
/// * `allow-credentials`: `true`. Configurable.
/// * `max-age`: `86400`. Configurable.
/// * `expose-headers`: `grpc-status,grpc-message`. Configurable but values can only be added.
/// `grpc-status` and `grpc-message` will always be exposed.
#[derive(Debug, Clone)]
pub struct Config {
pub(crate) allowed_origins: AllowedOrigins,
pub(crate) exposed_headers: HashSet<HeaderName>,
pub(crate) max_age: Option<Duration>,
pub(crate) allow_credentials: bool,
}
#[derive(Debug, Clone)]
pub(crate) enum AllowedOrigins {
Any,
#[allow(clippy::mutable_key_type)]
Only(BTreeSet<HeaderValue>),
}
impl AllowedOrigins {
pub(crate) fn is_allowed(&self, origin: &HeaderValue) -> bool {
match self {
AllowedOrigins::Any => true,
AllowedOrigins::Only(origins) => origins.contains(origin),
}
}
}
impl Config {
pub(crate) fn new() -> Config {
Config {
allowed_origins: AllowedOrigins::Any,
exposed_headers: DEFAULT_EXPOSED_HEADERS
.iter()
.cloned()
.map(HeaderName::from_static)
.collect(),
max_age: Some(DEFAULT_MAX_AGE),
allow_credentials: true,
}
}
/// Allow any origin to access this resource.
///
/// This is the default value.
pub fn allow_all_origins(self) -> Config {
Self {
allowed_origins: AllowedOrigins::Any,
..self
}
}
/// Only allow a specific set of origins to access this resource.
///
/// ## Example
///
/// ```
/// tonic_web::config().allow_origins(vec!["http://a.com", "http://b.com"]);
/// ```
pub fn allow_origins<I>(self, origins: I) -> Config
where
I: IntoIterator,
HeaderValue: TryFrom<I::Item>,
{
// false positive when using HeaderValue, which uses Bytes internally
// https://rust-lang.github.io/rust-clippy/master/index.html#mutable_key_type
#[allow(clippy::mutable_key_type)]
let origins = origins
.into_iter()
.map(|v| match TryFrom::try_from(v) {
Ok(uri) => uri,
Err(_) => panic!("invalid origin"),
})
.collect();
Self {
allowed_origins: AllowedOrigins::Only(origins),
..self
}
}
/// Adds multiple headers to the list of exposed headers.
///
/// Default: `grpc-status,grpc-message`. These will always be included.
pub fn expose_headers<I>(mut self, headers: I) -> Config
where
I: IntoIterator,
HeaderName: TryFrom<I::Item>,
{
let iter = headers
.into_iter()
.map(|header| match TryFrom::try_from(header) {
Ok(header) => header,
Err(_) => panic!("invalid header"),
});
self.exposed_headers.extend(iter);
self
}
/// Defines the maximum cache lifetime for operations allowed on this
/// resource.
///
/// Default: "86400" (24 hours)
pub fn max_age<T: Into<Option<Duration>>>(self, max_age: T) -> Config {
Self {
max_age: max_age.into(),
..self
}
}
/// If true, the `access-control-allow-credentials` will be sent.
///
/// Default: true
pub fn allow_credentials(self, allow_credentials: bool) -> Config {
Self {
allow_credentials,
..self
}
}
/// enable a tonic service to handle grpc-web requests with this configuration values.
pub fn enable<S>(&self, service: S) -> GrpcWeb<S>
where
S: Service<http::Request<hyper::Body>, Response = http::Response<BoxBody>>,
S: NamedService + Clone + Send + 'static,
S::Future: Send + 'static,
S::Error: Into<BoxError> + Send,
{
tracing::trace!("enabled for {}", S::NAME);
GrpcWeb::new(service, self.clone())
}
}
impl Default for Config {
fn default() -> Self {
Config::new()
}
}
+402
View File
@@ -0,0 +1,402 @@
use std::sync::Arc;
pub(crate) use http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS as ALLOW_CREDENTIALS;
pub(crate) use http::header::ACCESS_CONTROL_ALLOW_HEADERS as ALLOW_HEADERS;
pub(crate) use http::header::ACCESS_CONTROL_ALLOW_METHODS as ALLOW_METHODS;
pub(crate) use http::header::ACCESS_CONTROL_ALLOW_ORIGIN as ALLOW_ORIGIN;
pub(crate) use http::header::ACCESS_CONTROL_EXPOSE_HEADERS as EXPOSE_HEADERS;
pub(crate) use http::header::ACCESS_CONTROL_MAX_AGE as MAX_AGE;
pub(crate) use http::header::ACCESS_CONTROL_REQUEST_HEADERS as REQUEST_HEADERS;
pub(crate) use http::header::ACCESS_CONTROL_REQUEST_METHOD as REQUEST_METHOD;
pub(crate) use http::header::ORIGIN;
use http::{header, HeaderMap, HeaderValue, Method};
use tracing::debug;
use crate::config::Config;
const DEFAULT_ALLOWED_METHODS: &[Method; 2] = &[Method::POST, Method::OPTIONS];
#[derive(Debug, Clone)]
pub(crate) struct Cors {
cache: Arc<Cache>,
}
#[derive(Debug, PartialEq)]
pub(crate) enum Error {
OriginNotAllowed,
MethodNotAllowed,
}
#[derive(Clone, Debug)]
struct Cache {
config: Config,
expose_headers: HeaderValue,
allow_methods: HeaderValue,
allow_credentials: HeaderValue,
}
impl Cors {
pub(crate) fn new(config: Config) -> Cors {
let expose_headers = join_header_value(&config.exposed_headers).unwrap();
let allow_methods = HeaderValue::from_static("POST,OPTIONS");
let allow_credentials = HeaderValue::from_static("true");
let cache = Arc::new(Cache {
config,
expose_headers,
allow_methods,
allow_credentials,
});
Cors { cache }
}
fn is_method_allowed(&self, header: Option<&HeaderValue>) -> bool {
match header {
Some(value) => match Method::from_bytes(value.as_bytes()) {
Ok(method) => DEFAULT_ALLOWED_METHODS.contains(&method),
Err(_) => {
debug!("access-control-request-method {:?} is not valid", value);
false
}
},
None => {
debug!("access-control-request-method is missing");
false
}
}
}
pub(crate) fn preflight(
&self,
req_headers: &HeaderMap,
origin: &HeaderValue,
request_headers_header: &HeaderValue,
) -> Result<HeaderMap, Error> {
if !self.is_origin_allowed(origin) {
return Err(Error::OriginNotAllowed);
}
if !self.is_method_allowed(req_headers.get(REQUEST_METHOD)) {
return Err(Error::MethodNotAllowed);
}
let mut headers = self.common_headers(origin.clone());
headers.insert(ALLOW_METHODS, self.cache.allow_methods.clone());
headers.insert(ALLOW_HEADERS, request_headers_header.clone());
if let Some(max_age) = self.cache.config.max_age {
headers.insert(MAX_AGE, HeaderValue::from(max_age.as_secs()));
}
Ok(headers)
}
pub(crate) fn simple(&self, headers: &HeaderMap) -> Result<HeaderMap, Error> {
match headers.get(header::ORIGIN) {
Some(origin) if self.is_origin_allowed(origin) => {
Ok(self.common_headers(origin.clone()))
}
Some(_) => Err(Error::OriginNotAllowed),
None => Ok(HeaderMap::new()),
}
}
fn common_headers(&self, origin: HeaderValue) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(ALLOW_ORIGIN, origin);
headers.insert(EXPOSE_HEADERS, self.cache.expose_headers.clone());
if self.cache.config.allow_credentials {
headers.insert(ALLOW_CREDENTIALS, self.cache.allow_credentials.clone());
}
headers
}
fn is_origin_allowed(&self, origin: &HeaderValue) -> bool {
self.cache.config.allowed_origins.is_allowed(origin)
}
#[cfg(test)]
pub(crate) fn __check_preflight(&self, headers: &HeaderMap) -> Result<HeaderMap, Error> {
self.preflight(
headers,
headers.get(ORIGIN).unwrap(),
headers.get(REQUEST_HEADERS).unwrap(),
)
}
}
#[cfg(test)]
impl Default for Cors {
fn default() -> Self {
Cors::new(Config::default())
}
}
fn join_header_value<I>(values: I) -> Result<HeaderValue, header::InvalidHeaderValue>
where
I: IntoIterator,
I::Item: AsRef<str>,
{
let mut values = values.into_iter();
let mut value = Vec::new();
if let Some(v) = values.next() {
value.extend(v.as_ref().as_bytes());
}
for v in values {
value.push(b',');
value.extend(v.as_ref().as_bytes());
}
HeaderValue::from_bytes(&value)
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! assert_value_eq {
($header:expr, $expected:expr) => {
fn sorted(value: &str) -> Vec<&str> {
let mut vec = value.split(",").collect::<Vec<_>>();
vec.sort();
vec
}
assert_eq!(sorted($header.to_str().unwrap()), sorted($expected))
};
}
fn value(s: &str) -> HeaderValue {
s.parse().unwrap()
}
impl From<Config> for Cors {
fn from(c: Config) -> Self {
Cors::new(c)
}
}
#[test]
#[should_panic]
#[ignore]
fn origin_is_valid_url() {
Config::new().allow_origins(vec!["foo"]);
}
mod preflight {
use super::*;
fn preflight_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(ORIGIN, value("http://example.com"));
headers.insert(REQUEST_METHOD, value("POST"));
headers.insert(REQUEST_HEADERS, value("x-grpc-web"));
headers
}
#[test]
fn default_config() {
let cors = Cors::default();
let headers = cors.__check_preflight(&preflight_headers()).unwrap();
assert_eq!(headers[ALLOW_ORIGIN], "http://example.com");
assert_eq!(headers[ALLOW_METHODS], "POST,OPTIONS");
assert_eq!(headers[ALLOW_HEADERS], "x-grpc-web");
assert_eq!(headers[ALLOW_CREDENTIALS], "true");
assert_eq!(headers[MAX_AGE], "86400");
assert_value_eq!(&headers[EXPOSE_HEADERS], "grpc-status,grpc-message");
}
#[test]
fn any_origin() {
let cors: Cors = Config::new().allow_all_origins().into();
assert!(cors.__check_preflight(&preflight_headers()).is_ok());
}
#[test]
fn origin_list() {
let cors: Cors = Config::new()
.allow_origins(vec![
HeaderValue::from_static("http://a.com"),
HeaderValue::from_static("http://b.com"),
])
.into();
let mut req_headers = preflight_headers();
req_headers.insert(ORIGIN, value("http://b.com"));
assert!(cors.__check_preflight(&req_headers).is_ok());
}
#[test]
fn origin_not_allowed() {
let cors: Cors = Config::new().allow_origins(vec!["http://a.com"]).into();
let err = cors.__check_preflight(&preflight_headers()).unwrap_err();
assert_eq!(err, Error::OriginNotAllowed)
}
#[test]
fn disallow_credentials() {
let cors = Cors::new(Config::new().allow_credentials(false));
let headers = cors.__check_preflight(&preflight_headers()).unwrap();
assert!(!headers.contains_key(ALLOW_CREDENTIALS));
}
#[test]
fn expose_headers_are_merged() {
let cors = Cors::new(Config::new().expose_headers(vec!["x-request-id"]));
let headers = cors.__check_preflight(&preflight_headers()).unwrap();
assert_value_eq!(
&headers[EXPOSE_HEADERS],
"x-request-id,grpc-message,grpc-status"
);
}
#[test]
fn allow_headers_echo_request_headers() {
let cors = Cors::default();
let mut request_headers = preflight_headers();
request_headers.insert(REQUEST_HEADERS, value("x-grpc-web,foo,x-request-id"));
let headers = cors.__check_preflight(&request_headers).unwrap();
assert_value_eq!(&headers[ALLOW_HEADERS], "x-grpc-web,foo,x-request-id");
}
#[test]
fn missing_request_method() {
let cors = Cors::default();
let mut request_headers = preflight_headers();
request_headers.remove(REQUEST_METHOD);
let err = cors.__check_preflight(&request_headers).unwrap_err();
assert_eq!(err, Error::MethodNotAllowed);
}
#[test]
fn only_options_and_post_allowed() {
let cors = Cors::default();
for method in &[
Method::GET,
Method::DELETE,
Method::TRACE,
Method::PATCH,
Method::PUT,
Method::HEAD,
] {
let mut request_headers = preflight_headers();
request_headers.insert(REQUEST_METHOD, value(method.as_str()));
assert_eq!(
cors.__check_preflight(&request_headers).unwrap_err(),
Error::MethodNotAllowed,
)
}
}
#[test]
fn custom_max_age() {
use std::time::Duration;
let cors = Cors::new(Config::new().max_age(Duration::from_secs(99)));
let headers = cors.__check_preflight(&preflight_headers()).unwrap();
assert_eq!(headers[MAX_AGE], "99");
}
#[test]
fn no_max_age() {
let cors = Cors::new(Config::new().max_age(None));
let headers = cors.__check_preflight(&preflight_headers()).unwrap();
assert!(!headers.contains_key(MAX_AGE));
}
}
mod simple {
use super::*;
fn request_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(ORIGIN, value("http://example.com"));
headers
}
#[test]
fn default_config() {
let cors = Cors::default();
let headers = cors.simple(&request_headers()).unwrap();
assert_eq!(headers[ALLOW_ORIGIN], "http://example.com");
assert_eq!(headers[ALLOW_CREDENTIALS], "true");
assert_value_eq!(&headers[EXPOSE_HEADERS], "grpc-message,grpc-status");
assert!(!headers.contains_key(ALLOW_HEADERS));
assert!(!headers.contains_key(ALLOW_METHODS));
assert!(!headers.contains_key(MAX_AGE));
}
#[test]
fn any_origin() {
let cors: Cors = Config::new().allow_all_origins().into();
assert!(cors.simple(&request_headers()).is_ok());
}
#[test]
fn origin_list() {
let cors: Cors = Config::new()
.allow_origins(vec![
HeaderValue::from_static("http://a.com"),
HeaderValue::from_static("http://b.com"),
])
.into();
let mut req_headers = request_headers();
req_headers.insert(ORIGIN, value("http://b.com"));
assert!(cors.simple(&req_headers).is_ok());
}
#[test]
fn origin_not_allowed() {
let cors: Cors = Config::new().allow_origins(vec!["http://a.com"]).into();
let err = cors.simple(&request_headers()).unwrap_err();
assert_eq!(err, Error::OriginNotAllowed)
}
#[test]
fn disallow_credentials() {
let cors = Cors::new(Config::new().allow_credentials(false));
let headers = cors.simple(&request_headers()).unwrap();
assert!(!headers.contains_key(ALLOW_CREDENTIALS));
}
#[test]
fn expose_headers_are_merged() {
let cors: Cors = Config::new()
.expose_headers(vec!["x-hello", "custom-1"])
.into();
let headers = cors.simple(&request_headers()).unwrap();
assert_value_eq!(
&headers[EXPOSE_HEADERS],
"grpc-message,grpc-status,x-hello,custom-1"
);
}
}
}
+133
View File
@@ -0,0 +1,133 @@
//! grpc-web protocol translation for [`tonic`] services.
//!
//! [`tonic_web`] enables tonic servers to handle requests from [grpc-web] clients directly,
//! without the need of an external proxy. It achieves this by wrapping individual tonic services
//! with a [tower] service that performs the translation between protocols and handles `cors`
//! requests.
//!
//! ## Getting Started
//!
//! ```toml
//! [dependencies]
//! tonic_web = "0.1"
//! ```
//!
//! ## Enabling tonic services
//!
//! The easiest way to get started, is to call the [`enable`] function with your tonic service
//! and allow the tonic server to accept HTTP/1.1 requests:
//!
//! ```ignore
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let addr = "[::1]:50051".parse().unwrap();
//! let greeter = GreeterServer::new(MyGreeter::default());
//!
//! Server::builder()
//! .accept_http1(true)
//! .add_service(tonic_web::enable(greeter))
//! .serve(addr)
//! .await?;
//!
//! Ok(())
//! }
//!
//! ```
//! This will apply a default configuration that works well with grpc-web clients out of the box.
//! See the [`Config`] documentation for details.
//!
//! Alternatively, if you have a tls enabled server, you could skip setting `accept_http1` to `true`.
//! This works because the browser will handle `ALPN`.
//!
//! ```ignore
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let cert = tokio::fs::read("server.pem").await?;
//! let key = tokio::fs::read("server.key").await?;
//! let identity = Identity::from_pem(cert, key);
//!
//! let addr = "[::1]:50051".parse().unwrap();
//! let greeter = GreeterServer::new(MyGreeter::default());
//!
//! // No need to enable HTTP/1
//! Server::builder()
//! .tls_config(ServerTlsConfig::new().identity(identity))?
//! .add_service(tonic_web::enable(greeter))
//! .serve(addr)
//! .await?;
//!
//! Ok(())
//! }
//! ```
//!
//! ## Limitations
//!
//! * `tonic_web` is designed to work with grpc-web-compliant clients only. It is not expected to
//! handle arbitrary HTTP/x.x requests or bespoke protocols.
//! * Similarly, the cors support implemented by this crate will *only* handle grpc-web and
//! grpc-web preflight requests.
//! * Currently, grpc-web clients can only perform `unary` and `server-streaming` calls. These
//! are the only requests this crate is designed to handle. Support for client and bi-directional
//! streaming will be officially supported when clients do.
//! * There is no support for web socket transports.
//!
//!
//! [`tonic`]: https://github.com/hyperium/tonic
//! [`tonic_web`]: https://github.com/hyperium/tonic
//! [grpc-web]: https://github.com/grpc/grpc-web
//! [tower]: https://github.com/tower-rs/tower
//! [`enable`]: crate::enable()
//! [`Config`]: crate::Config
#![warn(
missing_debug_implementations,
missing_docs,
rust_2018_idioms,
unreachable_pub
)]
pub use config::Config;
mod call;
mod config;
mod cors;
mod service;
use crate::service::GrpcWeb;
use std::future::Future;
use std::pin::Pin;
use tonic::body::BoxBody;
use tonic::transport::NamedService;
use tower_service::Service;
/// enable a tonic service to handle grpc-web requests with the default configuration.
///
/// Shortcut for `tonic_web::config().enable(service)`
pub fn enable<S>(service: S) -> GrpcWeb<S>
where
S: Service<http::Request<hyper::Body>, Response = http::Response<BoxBody>>,
S: NamedService + Clone + Send + 'static,
S::Future: Send + 'static,
S::Error: Into<BoxError> + Send,
{
config().enable(service)
}
/// returns a default [`Config`] instance for configuring services.
///
/// ## Example
///
/// ```
/// let config = tonic_web::config()
/// .allow_origins(vec!["http://foo.com"])
/// .allow_credentials(false)
/// .expose_headers(vec!["x-request-id"]);
///
/// // let greeter = config.enable(Greeter);
/// // let route_guide = config.enable(RouteGuide);
/// ```
pub fn config() -> Config {
Config::default()
}
type BoxError = Box<dyn std::error::Error + Send + Sync>;
type BoxFuture<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;
+559
View File
@@ -0,0 +1,559 @@
use std::task::{Context, Poll};
use http::{header, HeaderMap, HeaderValue, Method, Request, Response, StatusCode, Version};
use hyper::Body;
use tonic::body::{empty_body, BoxBody};
use tonic::transport::NamedService;
use tower_service::Service;
use tracing::{debug, trace};
use crate::call::content_types::is_grpc_web;
use crate::call::{Encoding, GrpcWebCall};
use crate::cors::Cors;
use crate::cors::{ORIGIN, REQUEST_HEADERS};
use crate::{BoxError, BoxFuture, Config};
const GRPC: &str = "application/grpc";
#[derive(Debug, Clone)]
pub struct GrpcWeb<S> {
inner: S,
cors: Cors,
}
#[derive(Debug, PartialEq)]
enum RequestKind<'a> {
// The request is considered a grpc-web request if its `content-type`
// header is exactly one of:
//
// - "application/grpc-web"
// - "application/grpc-web+proto"
// - "application/grpc-web-text"
// - "application/grpc-web-text+proto"
GrpcWeb {
method: &'a Method,
encoding: Encoding,
accept: Encoding,
},
// The request is considered a grpc-web preflight request if all these
// conditions are met:
//
// - the request method is `OPTIONS`
// - request headers include `origin`
// - `access-control-request-headers` header is present and includes `x-grpc-web`
GrpcWebPreflight {
origin: &'a HeaderValue,
request_headers: &'a HeaderValue,
},
// All other requests, including `application/grpc`
Other(http::Version),
}
impl<S> GrpcWeb<S> {
pub(crate) fn new(inner: S, config: Config) -> Self {
GrpcWeb {
inner,
cors: Cors::new(config),
}
}
}
impl<S> GrpcWeb<S>
where
S: Service<Request<Body>, Response = Response<BoxBody>> + Send + 'static,
{
fn no_content(&self, headers: HeaderMap) -> BoxFuture<S::Response, S::Error> {
let mut res = Response::builder()
.status(StatusCode::NO_CONTENT)
.body(empty_body())
.unwrap();
res.headers_mut().extend(headers);
Box::pin(async { Ok(res) })
}
fn response(&self, status: StatusCode) -> BoxFuture<S::Response, S::Error> {
Box::pin(async move {
Ok(Response::builder()
.status(status)
.body(empty_body())
.unwrap())
})
}
}
impl<S> Service<Request<Body>> for GrpcWeb<S>
where
S: Service<Request<Body>, Response = Response<BoxBody>> + Send + 'static,
S::Future: Send + 'static,
S::Error: Into<BoxError> + Send,
{
type Response = S::Response;
type Error = S::Error;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
match RequestKind::new(req.headers(), req.method(), req.version()) {
// A valid grpc-web request, regardless of HTTP version.
//
// If the request includes an `origin` header, we verify it is allowed
// to access the resource, an HTTP 403 response is returned otherwise.
//
// If the origin is allowed to access the resource or there is no
// `origin` header present, translate the request into a grpc request,
// call the inner service, and translate the response back to
// grpc-web.
RequestKind::GrpcWeb {
method: &Method::POST,
encoding,
accept,
} => match self.cors.simple(req.headers()) {
Ok(headers) => {
trace!(kind = "simple", path = ?req.uri().path(), ?encoding, ?accept);
let fut = self.inner.call(coerce_request(req, encoding));
Box::pin(async move {
let mut res = coerce_response(fut.await?, accept);
res.headers_mut().extend(headers);
Ok(res)
})
}
Err(e) => {
debug!(kind = "simple", error=?e, ?req);
self.response(StatusCode::FORBIDDEN)
}
},
// The request's content-type matches one of the 4 supported grpc-web
// content-types, but the request method is not `POST`.
// This is not a valid grpc-web request, return HTTP 405.
RequestKind::GrpcWeb { .. } => {
debug!(kind = "simple", error="method not allowed", method = ?req.method());
self.response(StatusCode::METHOD_NOT_ALLOWED)
}
// A valid grpc-web preflight request, regardless of HTTP version.
// This is handled by the cors module.
RequestKind::GrpcWebPreflight {
origin,
request_headers,
} => match self.cors.preflight(req.headers(), origin, request_headers) {
Ok(headers) => {
trace!(kind = "preflight", path = ?req.uri().path(), ?origin);
self.no_content(headers)
}
Err(e) => {
debug!(kind = "preflight", error = ?e, ?req);
self.response(StatusCode::FORBIDDEN)
}
},
// All http/2 requests that are not grpc-web or grpc-web preflight
// are passed through to the inner service, whatever they are.
RequestKind::Other(Version::HTTP_2) => {
debug!(kind = "other h2", content_type = ?req.headers().get(header::CONTENT_TYPE));
Box::pin(self.inner.call(req))
}
// Return HTTP 400 for all other requests.
RequestKind::Other(_) => {
debug!(kind = "other h1", content_type = ?req.headers().get(header::CONTENT_TYPE));
self.response(StatusCode::BAD_REQUEST)
}
}
}
}
impl<S: NamedService> NamedService for GrpcWeb<S> {
const NAME: &'static str = S::NAME;
}
impl<'a> RequestKind<'a> {
fn new(headers: &'a HeaderMap, method: &'a Method, version: Version) -> Self {
if is_grpc_web(headers) {
return RequestKind::GrpcWeb {
method,
encoding: Encoding::from_content_type(headers),
accept: Encoding::from_accept(headers),
};
}
if let (&Method::OPTIONS, Some(origin), Some(value)) =
(method, headers.get(ORIGIN), headers.get(REQUEST_HEADERS))
{
match value.to_str() {
Ok(h) if h.contains("x-grpc-web") => {
return RequestKind::GrpcWebPreflight {
origin,
request_headers: value,
};
}
_ => {}
}
}
RequestKind::Other(version)
}
}
// Mutating request headers to conform to a gRPC request is not really
// necessary for us at this point. We could remove most of these except
// maybe for inserting `header::TE`, which tonic should check?
fn coerce_request(mut req: Request<Body>, encoding: Encoding) -> Request<Body> {
req.headers_mut().remove(header::CONTENT_LENGTH);
req.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(GRPC));
req.headers_mut()
.insert(header::TE, HeaderValue::from_static("trailers"));
req.headers_mut().insert(
header::ACCEPT_ENCODING,
HeaderValue::from_static("identity,deflate,gzip"),
);
req.map(|b| GrpcWebCall::request(b, encoding))
.map(Body::wrap_stream)
}
fn coerce_response(res: Response<BoxBody>, encoding: Encoding) -> Response<BoxBody> {
let mut res = res
.map(|b| GrpcWebCall::response(b, encoding))
.map(BoxBody::new);
res.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(encoding.to_content_type()),
);
res
}
#[cfg(test)]
mod tests {
use super::*;
use crate::call::content_types::*;
use http::header::{CONTENT_TYPE, ORIGIN};
#[derive(Clone)]
struct Svc;
impl tower_service::Service<Request<Body>> for Svc {
type Response = Response<BoxBody>;
type Error = String;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _: Request<Body>) -> Self::Future {
Box::pin(async { Ok(Response::new(empty_body())) })
}
}
impl NamedService for Svc {
const NAME: &'static str = "test";
}
mod grpc_web {
use super::*;
use http::HeaderValue;
fn request() -> Request<Body> {
Request::builder()
.method(Method::POST)
.header(CONTENT_TYPE, GRPC_WEB)
.header(ORIGIN, "http://example.com")
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn default_cors_config() {
let mut svc = crate::enable(Svc);
let res = svc.call(request()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn without_origin() {
let mut svc = crate::enable(Svc);
let mut req = request();
req.headers_mut().remove(ORIGIN);
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn origin_not_allowed() {
let mut svc = crate::config()
.allow_origins(vec!["http://localhost"])
.enable(Svc);
let res = svc.call(request()).await.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN)
}
#[tokio::test]
async fn only_post_allowed() {
let mut svc = crate::enable(Svc);
for method in &[
Method::GET,
Method::PUT,
Method::DELETE,
Method::HEAD,
Method::OPTIONS,
Method::PATCH,
] {
let mut req = request();
*req.method_mut() = method.clone();
let res = svc.call(req).await.unwrap();
assert_eq!(
res.status(),
StatusCode::METHOD_NOT_ALLOWED,
"{} should not be allowed",
method
);
}
}
#[tokio::test]
async fn grpc_web_content_types() {
let mut svc = crate::enable(Svc);
for ct in &[GRPC_WEB_TEXT, GRPC_WEB_PROTO, GRPC_WEB_PROTO, GRPC_WEB] {
let mut req = request();
req.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static(ct));
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
}
}
mod options {
use super::*;
use crate::cors::{REQUEST_HEADERS, REQUEST_METHOD};
use http::HeaderValue;
const SUCCESS: StatusCode = StatusCode::NO_CONTENT;
fn request() -> Request<Body> {
Request::builder()
.method(Method::OPTIONS)
.header(ORIGIN, "http://example.com")
.header(REQUEST_HEADERS, "x-grpc-web")
.header(REQUEST_METHOD, "POST")
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn origin_not_allowed() {
let mut svc = crate::config()
.allow_origins(vec!["http://foo.com"])
.enable(Svc);
let res = svc.call(request()).await.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn missing_request_method() {
let mut svc = crate::enable(Svc);
let mut req = request();
req.headers_mut().remove(REQUEST_METHOD);
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn only_post_and_options_allowed() {
let mut svc = crate::enable(Svc);
for method in &[
Method::GET,
Method::PUT,
Method::DELETE,
Method::HEAD,
Method::PATCH,
] {
let mut req = request();
req.headers_mut().insert(
REQUEST_METHOD,
HeaderValue::from_maybe_shared(method.to_string()).unwrap(),
);
let res = svc.call(req).await.unwrap();
assert_eq!(
res.status(),
StatusCode::FORBIDDEN,
"{} should not be allowed",
method
);
}
}
#[tokio::test]
async fn h1_missing_origin_is_err() {
let mut svc = crate::enable(Svc);
let mut req = request();
req.headers_mut().remove(ORIGIN);
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn h2_missing_origin_is_ok() {
let mut svc = crate::enable(Svc);
let mut req = request();
*req.version_mut() = Version::HTTP_2;
req.headers_mut().remove(ORIGIN);
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn h1_missing_x_grpc_web_header_is_err() {
let mut svc = crate::enable(Svc);
let mut req = request();
req.headers_mut().remove(REQUEST_HEADERS);
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn h2_missing_x_grpc_web_header_is_ok() {
let mut svc = crate::enable(Svc);
let mut req = request();
*req.version_mut() = Version::HTTP_2;
req.headers_mut().remove(REQUEST_HEADERS);
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[tokio::test]
async fn valid_grpc_web_preflight() {
let mut svc = crate::enable(Svc);
let res = svc.call(request()).await.unwrap();
assert_eq!(res.status(), SUCCESS);
}
}
mod grpc {
use super::*;
use http::HeaderValue;
fn request() -> Request<Body> {
Request::builder()
.version(Version::HTTP_2)
.header(CONTENT_TYPE, GRPC)
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn h2_is_ok() {
let mut svc = crate::enable(Svc);
let req = request();
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK)
}
#[tokio::test]
async fn h1_is_err() {
let mut svc = crate::enable(Svc);
let req = Request::builder()
.header(CONTENT_TYPE, GRPC)
.body(Body::empty())
.unwrap();
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST)
}
#[tokio::test]
async fn content_type_variants() {
let mut svc = crate::enable(Svc);
for variant in &["grpc", "grpc+proto", "grpc+thrift", "grpc+foo"] {
let mut req = request();
req.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_maybe_shared(format!("application/{}", variant)).unwrap(),
);
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK)
}
}
}
mod other {
use super::*;
fn request() -> Request<Body> {
Request::builder()
.header(CONTENT_TYPE, "application/text")
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn h1_is_err() {
let mut svc = crate::enable(Svc);
let res = svc.call(request()).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST)
}
#[tokio::test]
async fn h2_is_ok() {
let mut svc = crate::enable(Svc);
let mut req = request();
*req.version_mut() = Version::HTTP_2;
let res = svc.call(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK)
}
}
}