mirror of
https://github.com/AThilenius/axum-connect.git
synced 2024-11-24 06:19:46 +00:00
Update example to include error type and more detail
This commit is contained in:
parent
cd33439672
commit
eb39acaf30
5 changed files with 182 additions and 29 deletions
79
README.md
79
README.md
|
@ -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 🙍♂️
|
||||
|
||||
|
|
|
@ -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" }
|
||||
|
|
8
axum-connect-examples/proto/buf.yaml
Normal file
8
axum-connect-examples/proto/buf.yaml
Normal file
|
@ -0,0 +1,8 @@
|
|||
version: v1
|
||||
name: buf.build/athilenius/axum-connect
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
lint:
|
||||
use:
|
||||
- DEFAULT
|
62
axum-connect-examples/src/error.rs
Normal file
62
axum-connect-examples/src/error.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
Loading…
Reference in a new issue