feature: init the project with basic skeleton
This commit is contained in:
commit
7e0bbad356
|
@ -0,0 +1,2 @@
|
|||
build/
|
||||
smithy/build/
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,26 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"smithy/build/smithy/source/rust-client-codegen",
|
||||
"smithy/build/smithy/source/rust-server-codegen",
|
||||
"crates/server",
|
||||
"crates/service",
|
||||
"crates/client",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
axum = { version = "0.6", features = ["headers", "query", "tracing"] }
|
||||
echo-client-sdk = { path = "smithy/build/smithy/source/rust-client-codegen" }
|
||||
echo-server-sdk = { path = "smithy/build/smithy/source/rust-server-codegen" }
|
||||
echo-service = { path = "crates/service" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1" }
|
||||
tokio = { version = "1", features = [
|
||||
"rt",
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"time",
|
||||
] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
|
@ -0,0 +1,19 @@
|
|||
ASSETS = assets.tar.gz
|
||||
|
||||
validate:
|
||||
@cd smithy && smithy validate
|
||||
|
||||
update-smithy:
|
||||
@gh release download -R tyrchen/smithy-assets -p '$(ASSETS)'
|
||||
@rm -rf $HOME/.m2
|
||||
@tar -xzf $(ASSETS) -C $(HOME) --strip-components=2
|
||||
@rm $(ASSETS)
|
||||
|
||||
build-smithy:
|
||||
@cd smithy && smithy build
|
||||
|
||||
watch:
|
||||
@watchexec --restart --exts rs -- cargo run --bin echo-server
|
||||
|
||||
client:
|
||||
@cargo run --bin echo-client
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "echo-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
echo-client-sdk = { workspace = true }
|
||||
tokio = { workspace = true }
|
|
@ -0,0 +1,42 @@
|
|||
use anyhow::Result;
|
||||
use echo_client_sdk::{config::Token, Client, Config};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = Config::builder()
|
||||
.endpoint_url("http://localhost:3000/api")
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
let client = Client::from_conf(config);
|
||||
|
||||
println!("\n--- Calling echo_message operation without authentication");
|
||||
let ret = client.echo_message().message("example").send().await;
|
||||
println!("{:?}", ret);
|
||||
|
||||
println!("\n--- Calling signin operation to get a token");
|
||||
|
||||
let ret = client
|
||||
.signin()
|
||||
.username("test")
|
||||
.password("abcd12345")
|
||||
.send()
|
||||
.await;
|
||||
println!("{:?}", ret);
|
||||
|
||||
let token = ret?.token;
|
||||
|
||||
println!("\n-- Calling echo_message operation with authentication");
|
||||
|
||||
let config = Config::builder()
|
||||
.endpoint_url("http://localhost:3000/api")
|
||||
.bearer_token(Token::new(token, None))
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
let client = Client::from_conf(config);
|
||||
|
||||
let ret = client.echo_message().message("example").send().await?;
|
||||
|
||||
println!("{:?}", ret);
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "echo-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
aws-smithy-http-server = { version = "0.60", features = ["request-id"] }
|
||||
axum = { workspace = true }
|
||||
echo-service = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
|
@ -0,0 +1,17 @@
|
|||
use anyhow::Result;
|
||||
use echo_service::{get_router, AppConfig};
|
||||
use std::net::SocketAddr;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let app = get_router(AppConfig::default()).await;
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
|
||||
info!("Listening on {}", addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
[package]
|
||||
name = "echo-service"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
||||
[dependencies]
|
||||
aws-smithy-http-server = { version = "0.60" }
|
||||
axum = { workspace = true }
|
||||
axum-swagger-ui = "0.3"
|
||||
derive_more = { version = "1.0.0-beta.6", features = ["full"] }
|
||||
echo-server-sdk = { workspace = true }
|
||||
jwt-simple = "0.12.1"
|
||||
pin-project-lite = "0.2.13"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = "1.0.50"
|
||||
tokio = { workspace = true }
|
||||
tower = "0.4.13"
|
||||
tower-http = { version = "0.4", features = [
|
||||
"compression-full",
|
||||
"cors",
|
||||
"trace",
|
||||
"fs",
|
||||
] }
|
||||
tracing = { workspace = true }
|
||||
uuid7 = { version = "0.7.2", features = ["serde"] }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { version = "0.11.22", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"json",
|
||||
"gzip",
|
||||
] }
|
|
@ -0,0 +1,29 @@
|
|||
use crate::{forbidden, AppState};
|
||||
use aws_smithy_http_server::Extension;
|
||||
use echo_server_sdk::{error, input, output};
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn echo_message(
|
||||
input: input::EchoMessageInput,
|
||||
Extension(_state): Extension<Arc<AppState>>,
|
||||
) -> Result<output::EchoMessageOutput, error::EchoMessageError> {
|
||||
info!("echo: {:?}", input);
|
||||
let message = input.message;
|
||||
let output = output::EchoMessageOutput { message };
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub async fn signin(
|
||||
input: input::SigninInput,
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
) -> Result<output::SigninOutput, error::SigninError> {
|
||||
info!("signin: {:?}", input);
|
||||
let signer = &state.signer;
|
||||
let username = input.username;
|
||||
if input.password.len() < 8 {
|
||||
forbidden!("invalid password");
|
||||
}
|
||||
let token = signer.sign(username)?;
|
||||
Ok(output::SigninOutput { token })
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
use derive_more::Debug;
|
||||
use echo_server_sdk::error::{ForbiddenError, SigninError};
|
||||
use jwt_simple::prelude::*;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
pub sk: String,
|
||||
pub pk: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("jwt error: {0}")]
|
||||
JWTError(#[from] jwt_simple::Error),
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, AuthError>;
|
||||
|
||||
const TOKEN_DURATION: u64 = 14;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CustomClaims {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthSigner {
|
||||
provider: String,
|
||||
#[debug(skip)]
|
||||
key: Ed25519KeyPair,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthVerifier {
|
||||
provider: String,
|
||||
key: Ed25519PublicKey,
|
||||
}
|
||||
|
||||
impl AuthSigner {
|
||||
pub fn try_new(provider: impl Into<String>, key: impl AsRef<str>) -> Result<Self> {
|
||||
let key = Ed25519KeyPair::from_pem(key.as_ref())?;
|
||||
|
||||
Ok(Self {
|
||||
provider: provider.into(),
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sign(&self, data: String) -> Result<String> {
|
||||
let claims =
|
||||
Claims::with_custom_claims(CustomClaims { data }, Duration::from_days(TOKEN_DURATION))
|
||||
.with_issuer(&self.provider)
|
||||
.with_subject("auth");
|
||||
let token = self.key.sign(claims)?;
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthVerifier {
|
||||
pub fn try_new(provider: impl Into<String>, key: impl AsRef<str>) -> Result<Self> {
|
||||
let key = Ed25519PublicKey::from_pem(key.as_ref())?;
|
||||
Ok(Self {
|
||||
provider: provider.into(),
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify(&self, token: impl AsRef<str>) -> Result<JWTClaims<CustomClaims>> {
|
||||
let token = token.as_ref();
|
||||
let claims = self
|
||||
.key
|
||||
.verify_token::<CustomClaims>(token, Some(VerificationOptions::default()))?;
|
||||
Ok(claims)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
let sk = Ed25519KeyPair::generate();
|
||||
Self {
|
||||
sk: sk
|
||||
.to_pem()
|
||||
.split("-----BEGIN PUBLIC KEY-----")
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
pk: sk.public_key().to_pem(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for SigninError {
|
||||
fn from(e: AuthError) -> Self {
|
||||
Self::ForbiddenError(ForbiddenError {
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {}
|
|
@ -0,0 +1,97 @@
|
|||
#[macro_export]
|
||||
macro_rules! err {
|
||||
($ty:ident, $msg:expr) => {
|
||||
Err(echo_server_sdk::error::ServerError {
|
||||
code: echo_server_sdk::model::ErrorCode::$ty,
|
||||
message: $msg.to_string(),
|
||||
}.into())
|
||||
};
|
||||
($ty:ident, $msg:expr, $($param:expr),*) => {
|
||||
Err(echo_server_sdk::error::ServerError {
|
||||
code: echo_server_sdk::model::ErrorCode::$ty,
|
||||
message: format!($msg, $($param),*),
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! bail {
|
||||
($ty:ident, $msg:expr) => {
|
||||
return $crate::err!($ty, $msg)
|
||||
};
|
||||
($ty:ident, $msg:expr, $($param:expr),*) => {
|
||||
return $crate::err!($ty, $msg, $($param),*)
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! try_err {
|
||||
($expr:expr, $ty:ident) => {
|
||||
match $expr {
|
||||
Ok(v) => v,
|
||||
Err(e) => return $crate::err!($ty, e.to_string()),
|
||||
}
|
||||
};
|
||||
($expr:expr, $ty:ident, $msg:expr) => {
|
||||
match $expr {
|
||||
Some(v) => v,
|
||||
None => return $crate::err!($ty, $msg),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! not_found {
|
||||
( $msg:expr) => {
|
||||
return Err(echo_server_sdk::error::NotFoundError {
|
||||
message: $msg.to_string(),
|
||||
}.into())
|
||||
};
|
||||
($msg:expr, $($param:expr),*) => {
|
||||
return Err(echo_server_sdk::error::NotFoundError {
|
||||
message: format!($msg, $($param),*),
|
||||
}.into())
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! conflict {
|
||||
( $msg:expr) => {
|
||||
return Err(echo_server_sdk::error::ConflictError {
|
||||
message: $msg.to_string(),
|
||||
}.into())
|
||||
};
|
||||
($msg:expr, $($param:expr),*) => {
|
||||
return Err(echo_server_sdk::error::ConflictError {
|
||||
message: format!($msg, $($param),*),
|
||||
}.into())
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! forbidden {
|
||||
( $msg:expr) => {
|
||||
return Err(echo_server_sdk::error::ForbiddenError {
|
||||
message: $msg.to_string(),
|
||||
}.into())
|
||||
};
|
||||
($msg:expr, $($param:expr),*) => {
|
||||
return Err(echo_server_sdk::error::ForbiddenError {
|
||||
message: format!($msg, $($param),*),
|
||||
}.into())
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! unauthorized {
|
||||
( $msg:expr) => {
|
||||
return Err(echo_server_sdk::error::UnauthorizedError {
|
||||
message: $msg.to_string(),
|
||||
}.into())
|
||||
};
|
||||
($msg:expr, $($param:expr),*) => {
|
||||
return Err(echo_server_sdk::error::UnauthorizedError {
|
||||
message: format!($msg, $($param),*),
|
||||
}.into())
|
||||
};
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
mod api;
|
||||
mod auth;
|
||||
mod error;
|
||||
mod middleware;
|
||||
|
||||
use auth::{AuthConfig, AuthSigner, AuthVerifier};
|
||||
use aws_smithy_http_server::{
|
||||
plugin::IdentityPlugin, request::request_id::ServerRequestIdProviderLayer, AddExtensionLayer,
|
||||
};
|
||||
use axum::{
|
||||
http::{HeaderName, Method},
|
||||
response::Html,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum_swagger_ui::swagger_ui;
|
||||
use derive_more::Debug;
|
||||
use echo_server_sdk::{EchoService, EchoServiceConfig};
|
||||
use middleware::{BearerTokenProviderLayer, ServerTimingLayer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
#[allow(dead_code)]
|
||||
config: AppConfig,
|
||||
pub(crate) verifier: AuthVerifier,
|
||||
#[allow(dead_code)]
|
||||
pub(crate) signer: AuthSigner,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub server_name: String,
|
||||
pub port: u16,
|
||||
pub auth: AuthConfig,
|
||||
}
|
||||
|
||||
pub async fn get_router(conf: AppConfig) -> Router {
|
||||
// make name with static lifetime
|
||||
let name = Box::leak(Box::new(conf.server_name.clone()));
|
||||
|
||||
let state = Arc::new(AppState::new(conf));
|
||||
|
||||
let config = EchoServiceConfig::builder()
|
||||
// IdentityPlugin is a plugin that adds a middleware to the service, it just shows how to use plugins
|
||||
.http_plugin(IdentityPlugin)
|
||||
.layer(AddExtensionLayer::new(state.clone()))
|
||||
.layer(BearerTokenProviderLayer::new())
|
||||
.layer(ServerRequestIdProviderLayer::new_with_response_header(
|
||||
HeaderName::from_static("x-request-id"),
|
||||
))
|
||||
.build();
|
||||
let api = EchoService::builder(config)
|
||||
.echo_message(api::echo_message)
|
||||
.signin(api::signin)
|
||||
.build()
|
||||
.expect("failed to build an instance of Echo Service");
|
||||
|
||||
let doc_url = "/swagger/openapi.json";
|
||||
let doc = include_str!("../../../smithy/build/smithy/source/openapi/EchoService.openapi.json");
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
// allow `GET` and `POST` when accessing the resource
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::DELETE,
|
||||
Method::PATCH,
|
||||
Method::HEAD,
|
||||
Method::OPTIONS,
|
||||
])
|
||||
.allow_headers(Any)
|
||||
// allow requests from any origin
|
||||
.allow_origin(Any)
|
||||
.allow_private_network(true);
|
||||
|
||||
Router::new()
|
||||
.route("/swagger", get(|| async { Html(swagger_ui(doc_url)) }))
|
||||
.route(doc_url, get(move || async move { doc }))
|
||||
.nest_service("/api/", api)
|
||||
.layer(ServerTimingLayer::new(name))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_name: "echo-service".to_string(),
|
||||
port: 3000,
|
||||
auth: AuthConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
let signer = AuthSigner::try_new(&config.server_name, &config.auth.sk).unwrap();
|
||||
let verifier = AuthVerifier::try_new(&config.server_name, &config.auth.pk).unwrap();
|
||||
Self {
|
||||
config,
|
||||
verifier,
|
||||
signer,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
use crate::AppState;
|
||||
use aws_smithy_http_server::body::BoxBody;
|
||||
use axum::http::{Request, Response, StatusCode};
|
||||
use echo_server_sdk::server::response::IntoResponse;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use thiserror::Error;
|
||||
use tower::{Layer, Service};
|
||||
|
||||
/// The server request ID has not been added to the [`Request`](http::Request) or has been previously removed.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Error)]
|
||||
#[error("the `Authorization` header is not present in the `http::Request`")]
|
||||
pub enum BearTokenError {
|
||||
#[error("the `Authorization` header is not present in the `http::Request`")]
|
||||
Missing,
|
||||
#[error("the `Authorization` header is not valid")]
|
||||
Invalid,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BearerTokenProvider<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
/// A layer that provides services with bearer token
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct BearerTokenProviderLayer {}
|
||||
|
||||
impl BearerTokenProviderLayer {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for BearerTokenProviderLayer {
|
||||
type Service = BearerTokenProvider<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
BearerTokenProvider { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl<Body, S> Service<Request<Body>> for BearerTokenProvider<S>
|
||||
where
|
||||
S: Service<Request<Body>, Response = Response<BoxBody>>,
|
||||
S::Future: std::marker::Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = Pin<Box<dyn Send + Future<Output = Result<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 self.process(req) {
|
||||
Ok(req) => {
|
||||
let fut = self.inner.call(req);
|
||||
Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let res = <BearTokenError as IntoResponse<()>>::into_response(e);
|
||||
Box::pin(async move { Ok(res) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> BearerTokenProvider<S> {
|
||||
fn process<Body>(&self, mut req: Request<Body>) -> Result<Request<Body>, BearTokenError> {
|
||||
// TODO: how to read the smithy auth trait to see if the auth is required?
|
||||
let path = req.uri().path();
|
||||
if path.starts_with("/signin") || path.starts_with("/echo") {
|
||||
return Ok(req);
|
||||
}
|
||||
|
||||
let v = req
|
||||
.headers_mut()
|
||||
.remove("Authorization")
|
||||
.ok_or(BearTokenError::Missing)?;
|
||||
let v = v.to_str().map_err(|_| BearTokenError::Invalid)?;
|
||||
let token = v.trim_start_matches("Bearer ").to_string();
|
||||
|
||||
let verifier = &req.extensions().get::<Arc<AppState>>().unwrap().verifier;
|
||||
match verifier.verify(token) {
|
||||
Ok(claim) => {
|
||||
req.extensions_mut().insert(claim);
|
||||
|
||||
Ok(req)
|
||||
}
|
||||
Err(_) => Err(BearTokenError::Invalid),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Protocol> IntoResponse<Protocol> for BearTokenError {
|
||||
fn into_response(self) -> Response<BoxBody> {
|
||||
match self {
|
||||
BearTokenError::Missing => Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body(BoxBody::default())
|
||||
.unwrap(),
|
||||
BearTokenError::Invalid => Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body(BoxBody::default())
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {}
|
|
@ -0,0 +1,5 @@
|
|||
mod bearer_auth;
|
||||
mod server_timing;
|
||||
|
||||
pub use bearer_auth::BearerTokenProviderLayer;
|
||||
pub use server_timing::ServerTimingLayer;
|
|
@ -0,0 +1,243 @@
|
|||
// code from: https://github.com/JensWalter/axum-server-timing/blob/main/src/lib.rs
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
task::{ready, Context, Poll},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use axum::http::{HeaderValue, Request, Response};
|
||||
use pin_project_lite::pin_project;
|
||||
use tower::{Layer, Service};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerTimingLayer<'a> {
|
||||
app: &'a str,
|
||||
description: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> ServerTimingLayer<'a> {
|
||||
pub fn new(app: &'a str) -> Self {
|
||||
ServerTimingLayer {
|
||||
app,
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_description(&mut self, description: &'a str) -> Self {
|
||||
let mut new_self = self.clone();
|
||||
new_self.description = Some(description);
|
||||
new_self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, S> Layer<S> for ServerTimingLayer<'a> {
|
||||
type Service = ServerTimingService<'a, S>;
|
||||
|
||||
fn layer(&self, service: S) -> Self::Service {
|
||||
ServerTimingService {
|
||||
service,
|
||||
app: self.app,
|
||||
description: self.description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerTimingService<'a, S> {
|
||||
service: S,
|
||||
app: &'a str,
|
||||
description: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a, S, ReqBody, ResBody> Service<Request<ReqBody>> for ServerTimingService<'a, S>
|
||||
where
|
||||
S: Service<Request<ReqBody>, Response = Response<ResBody>>,
|
||||
ResBody: Default,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = ResponseFuture<'a, S::Future>;
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.service.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
||||
let (parts, body) = req.into_parts();
|
||||
|
||||
let req = Request::from_parts(parts, body);
|
||||
ResponseFuture {
|
||||
inner: self.service.call(req),
|
||||
request_time: Instant::now(),
|
||||
app: self.app,
|
||||
description: self.description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pin_project! {
|
||||
pub struct ResponseFuture<'a, F> {
|
||||
#[pin]
|
||||
inner: F,
|
||||
request_time: Instant,
|
||||
app: &'a str,
|
||||
description: Option<&'a str>,
|
||||
}
|
||||
}
|
||||
|
||||
impl<F, B, E> Future for ResponseFuture<'_, F>
|
||||
where
|
||||
F: Future<Output = Result<Response<B>, E>>,
|
||||
B: Default,
|
||||
{
|
||||
type Output = Result<Response<B>, E>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let time = self.request_time;
|
||||
let app = self.app;
|
||||
let description = self.description;
|
||||
let mut response: Response<B> = ready!(self.project().inner.poll(cx))?;
|
||||
let hdr = response.headers_mut();
|
||||
let x = time.elapsed().as_millis();
|
||||
let header_value = match description {
|
||||
Some(val) => format!("{app};desc=\"{val}\";dur={x}"),
|
||||
None => format!("{app};dur={x}"),
|
||||
};
|
||||
match hdr.try_entry("Server-Timing") {
|
||||
Ok(entry) => {
|
||||
match entry {
|
||||
axum::http::header::Entry::Occupied(mut val) => {
|
||||
//has val
|
||||
let old_val = val.get();
|
||||
let new_val = format!("{header_value}, {}", old_val.to_str().unwrap());
|
||||
val.insert(HeaderValue::from_str(&new_val).unwrap());
|
||||
}
|
||||
axum::http::header::Entry::Vacant(val) => {
|
||||
val.insert(HeaderValue::from_str(&header_value).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
hdr.append(
|
||||
"Server-Timing",
|
||||
HeaderValue::from_str(&header_value).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Poll::Ready(Ok(response))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn service_name() {
|
||||
let name = "svc1";
|
||||
let obj = ServerTimingLayer::new(name);
|
||||
assert_eq!(obj.app, name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_desc() {
|
||||
let name = "svc1";
|
||||
let desc = "desc1";
|
||||
let obj = ServerTimingLayer::new(name).with_description(desc);
|
||||
assert_eq!(obj.app, name);
|
||||
assert_eq!(obj.description, Some(desc));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_exists_on_response() {
|
||||
let name = "svc1";
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async move { "" }))
|
||||
.layer(ServerTimingLayer::new(name));
|
||||
|
||||
tokio::spawn(async {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3001));
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
//test request
|
||||
let resp = reqwest::get("http://localhost:3001/").await.unwrap();
|
||||
let hdr = resp.headers().get("server-timing");
|
||||
assert!(hdr.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_value() {
|
||||
let name = "svc1";
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(|| async move {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
""
|
||||
}),
|
||||
)
|
||||
.layer(ServerTimingLayer::new(name));
|
||||
|
||||
tokio::spawn(async {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3002));
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
//test request
|
||||
let resp = reqwest::get("http://localhost:3002/").await.unwrap();
|
||||
if let Some(hdr) = resp.headers().get("server-timing") {
|
||||
let val = &hdr.to_str().unwrap()[9..];
|
||||
let val_num: f32 = val.parse().unwrap();
|
||||
assert!(val_num >= 100_f32);
|
||||
} else {
|
||||
panic!("no header found");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn support_existing_header() {
|
||||
let name = "svc1";
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(|| async move {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let mut hdr = HeaderMap::new();
|
||||
hdr.insert("server-timing", HeaderValue::from_static("inner;dur=23"));
|
||||
(hdr, "")
|
||||
}),
|
||||
)
|
||||
.layer(ServerTimingLayer::new(name));
|
||||
|
||||
tokio::spawn(async {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3003));
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
//test request
|
||||
let resp = reqwest::get("http://localhost:3003/").await.unwrap();
|
||||
let hdr = resp.headers().get("server-timing").unwrap();
|
||||
let hdr_str = hdr.to_str().unwrap();
|
||||
assert!(hdr_str.contains("svc1"));
|
||||
assert!(hdr_str.contains("inner"));
|
||||
println!("{hdr:?}");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
$version: "2.0"
|
||||
|
||||
namespace com.example
|
||||
|
||||
/// Throttling error.
|
||||
@error("client")
|
||||
@retryable
|
||||
@httpError(429)
|
||||
structure ThrottlingError {
|
||||
@required
|
||||
message: String
|
||||
}
|
||||
|
||||
/// Not found error.
|
||||
@error("client")
|
||||
@httpError(404)
|
||||
structure NotFoundError {
|
||||
@required
|
||||
message: String
|
||||
}
|
||||
|
||||
/// Conflict error.
|
||||
@error("client")
|
||||
@httpError(409)
|
||||
structure ConflictError {
|
||||
@required
|
||||
message: String
|
||||
}
|
||||
|
||||
/// Unauthorized error.
|
||||
@error("client")
|
||||
@httpError(401)
|
||||
structure UnauthorizedError {
|
||||
@required
|
||||
message: String
|
||||
}
|
||||
|
||||
/// Forbidden error.
|
||||
@error("client")
|
||||
@httpError(403)
|
||||
structure ForbiddenError {
|
||||
@required
|
||||
message: String
|
||||
}
|
||||
|
||||
/// Server error.
|
||||
@error("server")
|
||||
@httpError(500)
|
||||
structure ServerError {
|
||||
@required
|
||||
code: ErrorCode
|
||||
@required
|
||||
message: String
|
||||
}
|
||||
|
||||
enum ErrorCode {
|
||||
INFER = "infer",
|
||||
NETWORK = "network",
|
||||
DATABASE = "database",
|
||||
UNKNOWN = "unknown",
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
$version: "2.0"
|
||||
|
||||
namespace com.example
|
||||
|
||||
use aws.protocols#restJson1
|
||||
use smithy.framework#ValidationException
|
||||
use aws.api#service
|
||||
|
||||
|
||||
/// Echoes input
|
||||
@service(sdkId: "echo")
|
||||
@restJson1
|
||||
@httpBearerAuth
|
||||
service EchoService {
|
||||
version: "2023-12-03"
|
||||
operations: [EchoMessage, Signin]
|
||||
}
|
||||
|
||||
@http(uri: "/echo", method: "POST")
|
||||
@auth([])
|
||||
operation EchoMessage {
|
||||
input := {
|
||||
@required
|
||||
@httpHeader("x-echo-message")
|
||||
message: String
|
||||
}
|
||||
output := {
|
||||
@required
|
||||
message: String
|
||||
}
|
||||
errors: [ValidationException]
|
||||
}
|
||||
|
||||
|
||||
/// Signin to get a token.
|
||||
@http(uri: "/signin", method: "POST")
|
||||
@auth([])
|
||||
operation Signin {
|
||||
input := {
|
||||
@required
|
||||
username: String
|
||||
@required
|
||||
password: String
|
||||
}
|
||||
output := {
|
||||
@required
|
||||
token: String
|
||||
}
|
||||
errors: [ValidationException, UnauthorizedError, ForbiddenError, ThrottlingError]
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"version": "1.0",
|
||||
"sources": [
|
||||
"model"
|
||||
],
|
||||
"maven": {
|
||||
"dependencies": [
|
||||
"software.amazon.smithy:smithy-model:[1.41.0,2.0)",
|
||||
"software.amazon.smithy:smithy-validation-model:[1.41.0,2.0)",
|
||||
"software.amazon.smithy:smithy-aws-traits:[1.41.0,2.0)",
|
||||
"software.amazon.smithy:smithy-openapi:1.41.1",
|
||||
"software.amazon.smithy.python:smithy-python-codegen:0.1.0",
|
||||
"software.amazon.smithy.typescript:smithy-typescript-codegen:0.19.0",
|
||||
"software.amazon.smithy.typescript:smithy-aws-typescript-codegen:0.19.0",
|
||||
"software.amazon.smithy:smithy-swift-codegen:0.1.0",
|
||||
"software.amazon.smithy.rust.codegen:codegen-client:0.1.0",
|
||||
"software.amazon.smithy.rust.codegen.server.smithy:codegen-server:0.1.0"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"openapi": {
|
||||
"service": "com.example#EchoService",
|
||||
"protocol": "aws.protocols#restJson1",
|
||||
"version": "3.1.0",
|
||||
"tags": true,
|
||||
"useIntegerType": true,
|
||||
"jsonAdd": {
|
||||
"/servers": [
|
||||
{
|
||||
"url": "http://localhost:3000/api",
|
||||
"description": "Local server"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"rust-server-codegen": {
|
||||
"service": "com.example#EchoService",
|
||||
"module": "echo-server-sdk",
|
||||
"moduleDescription": "Rust server SDK for echo server",
|
||||
"moduleVersion": "0.1.0",
|
||||
"moduleAuthors": [
|
||||
"Tyr Chen <tchen@abc.xyz>"
|
||||
],
|
||||
"runtimeConfig": {
|
||||
"versions": {
|
||||
"DEFAULT": "1.0.1",
|
||||
"aws-smithy-http": "0.60.0",
|
||||
"aws-smithy-json": "0.60.0",
|
||||
"aws-smithy-http-server": "0.60.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"python-client-codegen": {
|
||||
"service": "com.example#EchoService",
|
||||
"module": "echo",
|
||||
"moduleVersion": "0.0.1"
|
||||
},
|
||||
"typescript-client-codegen": {
|
||||
"package": "echo",
|
||||
"packageVersion": "0.0.1"
|
||||
},
|
||||
"swift-codegen": {
|
||||
"service": "com.example#EchoService",
|
||||
"module": "echo",
|
||||
"moduleVersion": "0.0.1",
|
||||
"homepage": "https://github.com/tyrchen",
|
||||
"gitRepo": "https://github.com/tyrchen",
|
||||
"author": "Tyr Chen <tchen@abc.xyz>",
|
||||
"swiftVersion": "5.0"
|
||||
},
|
||||
"rust-client-codegen": {
|
||||
"service": "com.example#EchoService",
|
||||
"module": "echo-client-sdk",
|
||||
"moduleDescription": "Rust client SDK for echo service",
|
||||
"moduleVersion": "0.1.0",
|
||||
"moduleAuthors": [
|
||||
"Tyr Chen <tchen@abc.xyz>"
|
||||
],
|
||||
"runtimeConfig": {
|
||||
"versions": {
|
||||
"DEFAULT": "1.0.1",
|
||||
"aws-smithy-http": "0.60.0",
|
||||
"aws-smithy-json": "0.60.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
# Echo REST API
|
||||
|
||||
### signin
|
||||
|
||||
# User should use existing anonymous token to signin. Here just for demo purpose.
|
||||
|
||||
# @name login
|
||||
POST http://localhost:3000/api/signin
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "abcd1234"
|
||||
}
|
||||
|
||||
### echo
|
||||
|
||||
# retrieve token as echo service need it.
|
||||
|
||||
@token = {{ login.response.body.token }}
|
||||
|
||||
POST http://localhost:3000/api/echo
|
||||
Authorization: Bearer {{ token }}
|
||||
X-Echo-Message: hello world!
|
Loading…
Reference in New Issue