Migrate `/v1/`, `/v1/node` routes to heartwood

Signed-off-by: xphoniex <dj.2dixx@gmail.com>
This commit is contained in:
xphoniex 2022-11-22 10:54:57 +00:00
parent da55e521a1
commit acd7aeb9c9
No known key found for this signature in database
GPG Key ID: 9FACFECE1E3B3994
7 changed files with 963 additions and 125 deletions

798
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -16,17 +16,22 @@ logfmt = [
[dependencies] [dependencies]
anyhow = { version = "1" } anyhow = { version = "1" }
flate2 = { version = "1" } axum = { version = "0.5.16", default-features = false, features = ["json", "headers", "query"] }
lexopt = { version = "0.2.1" }
thiserror = { version = "1" }
tokio = { version = "1.21", default-features = false, features = ["macros", "rt-multi-thread"] }
tracing = { version = "0.1.37", default-features = false, features = ["std", "log"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "ansi", "fmt"] }
tracing-logfmt = { version = "0.2", optional = true }
axum = { version = "0.5.16", default-features = false }
axum-server = { version = "0.4.2", default-features = false } axum-server = { version = "0.4.2", default-features = false }
ethers-core = { version = "1.0" }
flate2 = { version = "1" }
hyper = { version = "0.14.17", default-features = false } hyper = { version = "0.14.17", default-features = false }
tower-http = { version = "0.3.4", default-features = false, features = ["trace"] } lexopt = { version = "0.2.1" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
siwe = { version = "0.5" }
thiserror = { version = "1" }
time = { version = "0.3.17" }
tokio = { version = "1.21", default-features = false, features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.3.4", default-features = false, features = ["trace", "cors"] }
tracing = { version = "0.1.37", default-features = false, features = ["std", "log"] }
tracing-logfmt = { version = "0.2", optional = true }
tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "ansi", "fmt"] }
[dependencies.radicle] [dependencies.radicle]
path = "../radicle" path = "../radicle"

119
radicle-httpd/src/api.rs Normal file
View File

@ -0,0 +1,119 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::body::{Body, BoxBody};
use axum::http::header::{AUTHORIZATION, CONTENT_TYPE};
use axum::http::Method;
use axum::response::{IntoResponse, Json};
use axum::routing::get;
use axum::{Extension, Router};
use hyper::http::{Request, Response};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::RwLock;
use tower_http::cors::{self, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing::Span;
use radicle::{Profile, Storage};
mod auth;
mod v1;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Identifier for sessions
type SessionId = String;
#[derive(Clone)]
pub struct Context {
profile: Arc<Profile>,
sessions: Arc<RwLock<HashMap<SessionId, auth::AuthState>>>,
}
impl Context {
pub fn new(profile: Arc<Profile>) -> Self {
Self {
profile,
sessions: Default::default(),
}
}
fn storage(&self) -> &Storage {
&self.profile.storage
}
}
pub fn router(ctx: Context) -> Router {
let root_router = Router::new()
.route("/", get(root_handler))
.layer(Extension(ctx.clone()));
Router::new()
.merge(root_router)
.merge(v1::router(ctx))
.layer(
CorsLayer::new()
.max_age(Duration::from_secs(86400))
.allow_origin(cors::Any)
.allow_methods([Method::GET, Method::POST, Method::PUT])
.allow_headers([CONTENT_TYPE, AUTHORIZATION]),
)
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| {
tracing::info_span!(
"request",
method = %request.method(),
uri = %request.uri(),
status = tracing::field::Empty,
latency = tracing::field::Empty,
)
})
.on_response(
|response: &Response<BoxBody>, latency: Duration, span: &Span| {
span.record("status", &tracing::field::debug(response.status()));
span.record("latency", &tracing::field::debug(latency));
tracing::info!("Processed");
},
),
)
}
async fn root_handler(Extension(ctx): Extension<Context>) -> impl IntoResponse {
let response = json!({
"message": "Welcome!",
"service": "radicle-httpd",
"version": format!("{}-{}", VERSION, env!("GIT_HEAD")),
"node": { "id": ctx.profile.public_key },
"path": "/",
"links": [
{
"href": "/v1/projects",
"rel": "projects",
"type": "GET"
},
{
"href": "/v1/node",
"rel": "node",
"type": "GET"
},
{
"href": "/v1/delegates/:urn/projects",
"rel": "projects",
"type": "GET"
}
]
});
Json(response)
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct PaginationQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
}

View File

@ -0,0 +1,90 @@
use std::convert::TryFrom;
use std::str::FromStr;
use ethers_core::types::{Signature, H160};
use serde::{Deserialize, Serialize, Serializer};
use time::OffsetDateTime;
use crate::error::Error;
#[derive(Clone)]
pub struct DateTime(OffsetDateTime);
impl Serialize for DateTime {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&format!("{}", self.0))
}
}
#[derive(Deserialize, Serialize)]
pub struct AuthRequest {
pub message: String,
#[serde(deserialize_with = "deserialize_signature")]
pub signature: Signature,
}
fn deserialize_signature<'de, D>(deserializer: D) -> Result<Signature, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let buf = String::deserialize(deserializer)?;
Signature::from_str(&buf).map_err(serde::de::Error::custom)
}
pub enum AuthState {
Authorized(Session),
Unauthorized {
nonce: String,
expiration_time: DateTime,
},
}
// We copy the implementation of siwe::Message here to derive Serialization and Debug
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub domain: String,
pub address: H160,
pub statement: Option<String>,
pub uri: String,
pub version: u64,
pub chain_id: u64,
pub nonce: String,
pub issued_at: DateTime,
pub expiration_time: Option<DateTime>,
pub resources: Vec<String>,
}
impl TryFrom<siwe::Message> for Session {
type Error = Error;
fn try_from(message: siwe::Message) -> Result<Session, Error> {
Ok(Session {
domain: message.domain.host().to_string(),
address: H160(message.address),
statement: None,
uri: message.uri.to_string(),
version: message.version as u64,
chain_id: message.chain_id,
nonce: message.nonce,
issued_at: DateTime(message.issued_at.as_ref().to_owned()),
expiration_time: message
.expiration_time
.map(|x| DateTime(x.as_ref().to_owned())),
resources: message.resources.iter().map(|r| r.to_string()).collect(),
})
}
}
#[cfg(test)]
mod test {
#[test]
fn test_auth_request_de() {
let json = serde_json::json!({
"message": "Hello World!",
"signature": "20096c6ed2bcccb88c9cafbbbbda7a5a3cff6d0ca318c07faa58464083ca40a92f899fbeb26a4c763a7004b13fd0f1ba6c321d4e3a023e30f63c40d4154b99a41c"
});
let _req: super::AuthRequest = serde_json::from_value(json).unwrap();
}
}

View File

@ -0,0 +1,11 @@
mod node;
use axum::Router;
use crate::api::Context;
pub fn router(ctx: Context) -> Router {
let routes = Router::new().merge(node::router(ctx));
Router::new().nest("/v1", routes)
}

View File

@ -0,0 +1,26 @@
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Extension, Json, Router};
use serde_json::json;
use radicle::node::NodeId;
use crate::api::Context;
pub fn router(ctx: Context) -> Router {
let node_id = ctx.profile.public_key;
Router::new()
.route("/node", get(node_handler))
.layer(Extension(node_id))
}
/// Return the node id for the node identity.
/// `GET /node`
async fn node_handler(Extension(node_id): Extension<NodeId>) -> impl IntoResponse {
let response = json!({
"id": node_id.to_string(),
});
Json(response)
}

View File

@ -32,6 +32,8 @@ use radicle::profile::Profile;
use error::Error; use error::Error;
mod api;
pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -39,7 +41,7 @@ pub struct Options {
pub listen: net::SocketAddr, pub listen: net::SocketAddr,
} }
/// Run the Git Server. /// Run the Server.
pub async fn run(options: Options) -> anyhow::Result<()> { pub async fn run(options: Options) -> anyhow::Result<()> {
let git_version = Command::new("git") let git_version = Command::new("git")
.arg("version") .arg("version")
@ -48,12 +50,12 @@ pub async fn run(options: Options) -> anyhow::Result<()> {
.stdout; .stdout;
tracing::info!("{}", str::from_utf8(&git_version)?.trim()); tracing::info!("{}", str::from_utf8(&git_version)?.trim());
let profile = radicle::Profile::load()?; let profile = Arc::new(radicle::Profile::load()?);
tracing::info!("using radicle home at {}", profile.home.display()); tracing::info!("using radicle home at {}", profile.home.display());
let app = Router::new() let git_router = Router::new()
.route("/:project/*request", any(git_handler)) .route("/:project/*request", any(git_handler))
.layer(Extension(Arc::new(profile))) .layer(Extension(profile.clone()))
.layer( .layer(
TraceLayer::new_for_http() TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| { .make_span_with(|request: &Request<Body>| {
@ -73,11 +75,18 @@ pub async fn run(options: Options) -> anyhow::Result<()> {
tracing::info!("Processed"); tracing::info!("Processed");
}, },
), ),
) );
.into_make_service_with_connect_info::<SocketAddr>();
let ctx = api::Context::new(profile);
let api_router = api::router(ctx);
tracing::info!("listening on http://{}", options.listen); tracing::info!("listening on http://{}", options.listen);
let app = Router::new()
.merge(git_router)
.nest("/api", api_router)
.into_make_service_with_connect_info::<SocketAddr>();
axum::Server::bind(&options.listen) axum::Server::bind(&options.listen)
.serve(app) .serve(app)
.await .await