diff --git a/README.md b/README.md index d654e44..9fef4f3 100644 --- a/README.md +++ b/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 { + 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 { + 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 🙍‍♂️ diff --git a/axum-connect-examples/Cargo.toml b/axum-connect-examples/Cargo.toml index 3883a74..b768950 100644 --- a/axum-connect-examples/Cargo.toml +++ b/axum-connect-examples/Cargo.toml @@ -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" } diff --git a/axum-connect-examples/proto/buf.yaml b/axum-connect-examples/proto/buf.yaml new file mode 100644 index 0000000..85a1266 --- /dev/null +++ b/axum-connect-examples/proto/buf.yaml @@ -0,0 +1,8 @@ +version: v1 +name: buf.build/athilenius/axum-connect +breaking: + use: + - FILE +lint: + use: + - DEFAULT diff --git a/axum-connect-examples/src/error.rs b/axum-connect-examples/src/error.rs new file mode 100644 index 0000000..d237906 --- /dev/null +++ b/axum-connect-examples/src/error.rs @@ -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() + } + } + } +} + diff --git a/axum-connect-examples/src/main.rs b/axum-connect-examples/src/main.rs index 4ca77c4..ca7fdb4 100644 --- a/axum-connect-examples/src/main.rs +++ b/axum-connect-examples/src/main.rs @@ -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 { + 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 {