Update example to include error type and more detail

This commit is contained in:
Alec Thilenius 2024-02-19 16:59:58 -08:00
parent cd33439672
commit eb39acaf30
5 changed files with 182 additions and 29 deletions

View file

@ -112,9 +112,20 @@ With the boring stuff out of the way, let's implement our service using Axum!
use async_stream::stream;
use axum::{extract::Host, Router};
use axum_connect::{futures::Stream, prelude::*};
use error::Error;
use proto::hello::*;
use tower_http::cors::CorsLayer;
// Take a peak at error.rs to see how errors work in axum-connect.
mod error;
mod proto {
// Include the generated code in a `proto` module.
//
// Note: I'm not super happy with this pattern. I hope to add support to `protoc-gen-prost` in
// the near-ish future instead see:
// https://github.com/neoeinstein/protoc-gen-prost/issues/82#issuecomment-1877107220 That will
// better align with Buf.build's philosophy. This is how it works for now though.
pub mod hello {
include!(concat!(env!("OUT_DIR"), "/hello.rs"));
}
@ -126,41 +137,75 @@ async fn main() {
// It expect a service method handler, wrapped in it's respective type. The handler (below) is
// just a normal Rust function. Just like Axum, it also supports extractors!
let app = Router::new()
.rpc(HelloWorldService::say_hello(say_hello_success))
.rpc(HelloWorldService::say_hello_stream(say_hello_stream));
// A standard unary (POST based) Connect-Web request handler.
.rpc(HelloWorldService::say_hello(say_hello_unary))
// A GET version of the same thing, which has well-defined semantics for caching.
.rpc(HelloWorldService::say_hello_unary_get(say_hello_unary))
// A server-streaming request handler. Very useful when you need them!
.rpc(HelloWorldService::say_hello_stream(stream_three_reponses));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3030")
.await
.unwrap();
println!("listening on http://{:?}", listener.local_addr());
axum::serve(listener, app).await.unwrap();
println!("listening on http://{:?}", listener.local_addr().unwrap());
axum::serve(listener, app.layer(CorsLayer::very_permissive()))
.await
.unwrap();
}
async fn say_hello_success(
Host(host): Host,
request: HelloRequest
) -> HelloResponse {
HelloResponse {
/// The bread-and-butter of Connect-Web, a Unary request handler.
///
/// Just to demo error handling, I've chose to return a `Result` here. If your method is
/// infallible, you could just as easily return a `HellResponse` directly. The error type I'm using
/// is defined in `error.rs` and is worth taking a quick peak at.
///
/// Like Axum, both the request AND response types just need to implement RpcFromRequestParts` and
/// `RpcIntoResponse` respectively. This allows for a ton of flexibility in what your handlers
/// actually accept/return. This is a concept very core to Axum, so I won't go too deep into the
/// ideology here.
async fn say_hello_unary(Host(host): Host, request: HelloRequest) -> Result<HelloResponse, Error> {
Ok(HelloResponse {
message: format!(
"Hello {}! You're addressing the hostname: {}.",
request.name, host
request.name.unwrap_or_else(|| "unnamed".to_string()),
host
),
})
}
/// This is a server-streaming request handler. Much more rare to see one in the wild, but they
/// sure are useful when you need them! axum-connect has only partial support for everything
/// connect-web defines in server-streaming requests. For example, it doesn't define a way to
/// return trailers. I've never once actually needed them, so it feels weird to muddy the API just
/// to support such a niche use. Trailers are IMO the worst single decision gRPC made, locking them
/// into HTTP/2 forever. I'm not a fan -.-
///
/// You can however return a stream of anything that converts `RpcIntoResponse`, just like the
/// unary handlers. Again, very flexible. In this case I'm using the amazing `async-stream` crate
/// to make the code nice and readable.
async fn stream_three_reponses(
Host(host): Host,
request: HelloRequest,
) -> impl Stream<Item = HelloResponse> {
stream! {
yield HelloResponse { message: "Hello".to_string() };
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
yield HelloResponse { message: request.name().to_string() };
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
yield HelloResponse { message: format!("You're addressing the hostname: {}", host) };
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
```
## SEND IT 🚀
To test it out, try hitting the endpoint manually.
```sh
curl --location 'http://localhost:3030/hello.HelloWorldService/SayHello' \
--header 'Content-Type: application/json' \
--data '{ "name": "Alec" }'
cargo run -p axum-connect-example
```
From here you can stand up a `connect-web` TypeScript/Go project to call your
API with end-to-end typed RPCs.
It's Connect RPCs, so you can use the Buf Studio to test things out!
https://buf.build/studio/athilenius/axum-connect/main/hello.HelloWorldService/SayHello?target=http%3A%2F%2Flocalhost%3A3030
# Request/Response Parts 🙍‍♂️

View file

@ -4,11 +4,14 @@ version = "0.2.0"
edition = "2021"
[dependencies]
anyhow = "1.0.80"
async-stream = "0.3.5"
axum = "0.7.2"
axum-connect = { path = "../axum-connect" }
prost = "0.12.1"
thiserror = "1.0.57"
tokio = { version = "1.0", features = ["full"] }
tower-http = { version = "0.5.1", features = ["cors"] }
[build-dependencies]
axum-connect-build = { path = "../axum-connect-build" }

View file

@ -0,0 +1,8 @@
version: v1
name: buf.build/athilenius/axum-connect
breaking:
use:
- FILE
lint:
use:
- DEFAULT

View file

@ -0,0 +1,62 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use axum_connect::error::{RpcError, RpcErrorCode, RpcIntoError};
// This is an example Error type, to demo impls needed for `axum-connect`. It uses `thiserror` to
// wrap various error types as syntactic sugar, but you could just as easily write this out by hand.
#[allow(dead_code)]
#[derive(thiserror::Error, Debug)]
pub enum Error {
/// Returns `403 Forbidden`
#[error("user may not perform that action")]
Forbidden,
/// Returns `404 Not Found`
#[error("request path not found")]
NotFound,
/// Returns `500 Internal Server Error`
#[error("an internal server error occurred")]
Anyhow(#[from] anyhow::Error),
}
/// Allows the error type to be returned from RPC handlers.
///
/// This trait is distinct from `IntoResponse` because RPCs cannot return arbitrary HTML responses.
/// Error codes are well-defined in connect-web (which mirrors gRPC), streaming errors don't effect
/// HTTP status codes, and so on.
impl RpcIntoError for Error {
fn rpc_into_error(self) -> axum_connect::prelude::RpcError {
println!("{:#?}", self);
// Each response is a tuple of well-defined (per the Connect-Web) codes, along with a
// message.
match self {
Self::Forbidden => {
RpcError::new(RpcErrorCode::PermissionDenied, "Forbidden".to_string())
}
Self::NotFound => RpcError::new(RpcErrorCode::NotFound, "Not Found".to_string()),
Self::Anyhow(_) => {
RpcError::new(RpcErrorCode::Internal, "Internal Server Error".to_string())
}
}
}
}
// This is a normal `IntoResponse` impl, which is used by Axum to convert errors into HTTP
// responses.
impl IntoResponse for Error {
fn into_response(self) -> Response {
println!("{:#?}", self);
match self {
Self::Forbidden => (StatusCode::FORBIDDEN, "Forbidden").into_response(),
Self::NotFound => (StatusCode::NOT_FOUND, "Not Found").into_response(),
Self::Anyhow(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response()
}
}
}
}

View file

@ -1,16 +1,26 @@
//!
//! $ cargo run -p axum-connect-example
//!
//! $ curl 'http://127.0.0.1:3030/hello.HelloWorldService/SayHello?encoding=json&message=%7B%7D' -v
//! $ curl 'http://127.0.0.1:3030/hello.HelloWorldService/SayHello?encoding=json&message=%7B%22name%22%3A%22foo%22%7D' -v
//! Head over to Buf Studio to test out RPCs, with auto-completion!
//! https://buf.build/studio/athilenius/axum-connect/main/hello.HelloWorldService/SayHello?target=http%3A%2F%2Flocalhost%3A3030
//!
use async_stream::stream;
use axum::{extract::Host, Router};
use axum_connect::{futures::Stream, prelude::*};
use error::Error;
use proto::hello::*;
use tower_http::cors::CorsLayer;
// Take a peak at error.rs to see how errors work in axum-connect.
mod error;
mod proto {
// Include the generated code in a `proto` module.
//
// Note: I'm not super happy with this pattern. I hope to add support to `protoc-gen-prost` in
// the near-ish future instead see:
// https://github.com/neoeinstein/protoc-gen-prost/issues/82#issuecomment-1877107220 That will
// better align with Buf.build's philosophy. This is how it works for now though.
pub mod hello {
include!(concat!(env!("OUT_DIR"), "/hello.rs"));
}
@ -22,28 +32,53 @@ async fn main() {
// It expect a service method handler, wrapped in it's respective type. The handler (below) is
// just a normal Rust function. Just like Axum, it also supports extractors!
let app = Router::new()
.rpc(HelloWorldService::say_hello(say_hello_success))
.rpc(HelloWorldService::say_hello_unary_get(say_hello_success))
.rpc(HelloWorldService::say_hello_stream(say_hello_stream));
// A standard unary (POST based) Connect-Web request handler.
.rpc(HelloWorldService::say_hello(say_hello_unary))
// A GET version of the same thing, which has well-defined semantics for caching.
.rpc(HelloWorldService::say_hello_unary_get(say_hello_unary))
// A server-streaming request handler. Very useful when you need them!
.rpc(HelloWorldService::say_hello_stream(stream_three_reponses));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3030")
.await
.unwrap();
println!("listening on http://{:?}", listener.local_addr());
axum::serve(listener, app).await.unwrap();
println!("listening on http://{:?}", listener.local_addr().unwrap());
axum::serve(listener, app.layer(CorsLayer::very_permissive()))
.await
.unwrap();
}
async fn say_hello_success(Host(host): Host, request: HelloRequest) -> HelloResponse {
HelloResponse {
/// The bread-and-butter of Connect-Web, a Unary request handler.
///
/// Just to demo error handling, I've chose to return a `Result` here. If your method is
/// infallible, you could just as easily return a `HellResponse` directly. The error type I'm using
/// is defined in `error.rs` and is worth taking a quick peak at.
///
/// Like Axum, both the request AND response types just need to implement RpcFromRequestParts` and
/// `RpcIntoResponse` respectively. This allows for a ton of flexibility in what your handlers
/// actually accept/return. This is a concept very core to Axum, so I won't go too deep into the
/// ideology here.
async fn say_hello_unary(Host(host): Host, request: HelloRequest) -> Result<HelloResponse, Error> {
Ok(HelloResponse {
message: format!(
"Hello {}! You're addressing the hostname: {}.",
request.name.unwrap_or_else(|| "unnamed".to_string()),
host
),
}
})
}
async fn say_hello_stream(
/// This is a server-streaming request handler. Much more rare to see one in the wild, but they
/// sure are useful when you need them! axum-connect has only partial support for everything
/// connect-web defines in server-streaming requests. For example, it doesn't define a way to
/// return trailers. I've never once actually needed them, so it feels weird to muddy the API just
/// to support such a niche use. Trailers are IMO the worst single decision gRPC made, locking them
/// into HTTP/2 forever. I'm not a fan -.-
///
/// You can however return a stream of anything that converts `RpcIntoResponse`, just like the
/// unary handlers. Again, very flexible. In this case I'm using the amazing `async-stream` crate
/// to make the code nice and readable.
async fn stream_three_reponses(
Host(host): Host,
request: HelloRequest,
) -> impl Stream<Item = HelloResponse> {