use std::{fmt::Display, str::FromStr};
use actix_service::{boxed, fn_service};
use actix_web::{
FromRequest, Handler, HttpResponse, Resource, Responder,
dev::{ServiceRequest, ServiceResponse},
http::{Method, StatusCode},
web::{Data, ServiceConfig},
};
use futures::future::LocalBoxFuture;
use serde::{Deserialize, Serialize};
use crate::{
server::ApiErrorV1,
utils::{BoxedServiceFactory, sanitize_path},
};
pub trait ResourceController: Send + Sync + 'static {
fn resources(&self) -> Vec<ResourceConfig>;
fn controller_config(&self, cfg: &mut ServiceConfig) {
let controller_scope = self.resources().into_iter().fold(
actix_web::web::scope(self.path()),
|mut agg, resource| {
agg = agg.service(
Resource::try_from(resource)
.expect("Failed to convert ResourceConfig to Resource"),
);
agg
},
);
cfg.service(controller_scope);
}
fn path(&self) -> &str;
}
pub struct ResourceConfig {
name: String,
path: String,
description: Option<String>,
labels: Vec<ServiceLabel>,
routes: Vec<(Method, BoxedServiceFactory)>,
}
impl ResourceConfig {
pub fn new<S: Into<String>>(name: S) -> Self {
Self {
name: name.into(),
path: String::new(),
description: None,
labels: Vec::new(),
routes: Vec::new(),
}
}
pub fn with_path<P: Into<String>>(mut self, path: P) -> Self {
self.path = sanitize_path(path.into());
self
}
pub fn with_description<S: Into<String>>(mut self, description: S) -> Self {
self.description = Some(description.into());
self
}
pub fn with_labels<L: IntoIterator<Item = ServiceLabel>>(mut self, labels: L) -> Self {
self.labels.extend(labels);
self
}
pub fn with_route<M, F, T>(mut self, method: M, handler: F) -> Self
where
M: Into<Method>,
F: Handler<T>,
F::Output: Responder,
T: FromRequest,
{
let fac = boxed::factory(fn_service(move |req: ServiceRequest| {
let handler = handler.clone();
async move {
let (req, mut payload) = req.into_parts();
let res = match T::from_request(&req, &mut payload).await {
Ok(param) => handler
.call(param)
.await
.respond_to(&req)
.map_into_boxed_body(),
Err(err) => HttpResponse::from_error(err),
};
Ok(ServiceResponse::new(req, res))
}
}));
self.routes.push((method.into(), fac));
self
}
pub fn name(&self) -> &str {
self.name.as_str()
}
pub fn path(&self) -> &str {
self.path.as_str()
}
pub fn description(&self) -> Option<&String> {
self.description.as_ref()
}
pub fn labels(&self) -> &[ServiceLabel] {
self.labels.as_slice()
}
}
impl TryFrom<ResourceConfig> for actix_web::Resource {
type Error = anyhow::Error;
fn try_from(config: ResourceConfig) -> anyhow::Result<Self> {
let mut resource = actix_web::Resource::new(config.path())
.app_data(Data::new(config.labels().to_vec()))
.name(
&ServiceUrn::default()
.push_segment(config.name())?
.to_string(),
);
resource = config
.routes
.into_iter()
.fold(resource, |res, (method, handler)| {
res.route(actix_web::Route::new().method(method).service(handler))
});
Ok(resource)
}
}
#[derive(Clone, Debug, Deserialize, Serialize, Hash, Eq, PartialEq)]
pub struct ServiceLabel(String, String);
impl ServiceLabel {
pub fn new<S: Into<String>>(key: S, value: S) -> Self {
Self(key.into().to_lowercase(), value.into().to_lowercase())
}
pub fn key(&self) -> &str {
&self.0
}
pub fn value(&self) -> &str {
&self.1
}
}
impl From<(&str, &str)> for ServiceLabel {
fn from((key, value): (&str, &str)) -> Self {
Self::new(key, value)
}
}
impl Display for ServiceLabel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.key(), self.value())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ServiceUrn {
segments: Vec<String>,
}
impl Default for ServiceUrn {
fn default() -> Self {
let root_segment = Self::sanitize(env!("CARGO_PKG_NAME").to_string())
.unwrap_or_else(|_| Self::FALLBACK_ROOT_SEGMENT.to_string());
Self {
segments: vec![root_segment],
}
}
}
impl ServiceUrn {
const FALLBACK_ROOT_SEGMENT: &'static str = "restrepo";
const DEFAULT_ROOT_SEGMENT: &'static str = env!("CARGO_PKG_NAME");
pub fn push_segment<S: Into<String>>(mut self, segment: S) -> Result<Self, anyhow::Error> {
self.segments.push(Self::sanitize(segment.into())?);
Ok(self)
}
pub fn push_segments<I, S>(mut self, segments: I) -> Result<Self, anyhow::Error>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
segments
.into_iter()
.try_fold(&mut self.segments, |acc, segment| {
acc.push(Self::sanitize(segment.into())?);
Ok::<_, anyhow::Error>(acc)
})?;
Ok(self)
}
fn sanitize(s: String) -> anyhow::Result<String> {
if s.is_empty() {
return Err(anyhow::anyhow!("urn segment can not be empty"));
}
s.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
.then(|| s.to_lowercase())
.ok_or_else(|| {
anyhow::anyhow!(
"urn segment can only contain alphanumeric characters, dashes and underscores. Found: {s}"
)
})
}
}
impl Display for ServiceUrn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "urn:{}", self.segments.join(":"))?;
Ok(())
}
}
impl FromStr for ServiceUrn {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let mut segments = value.split(':');
if segments.next().is_none_or(|seg| seg != "urn") {
return Err(anyhow::anyhow!(
"Invalid format. URNs should be prefixed with 'urn'"
));
}
if segments.next().is_none_or(|seg| {
seg != Self::DEFAULT_ROOT_SEGMENT && seg != Self::FALLBACK_ROOT_SEGMENT
}) {
return Err(anyhow::anyhow!("Unknown urn root segment."));
}
Self::default().push_segments(segments)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RouteId {
method: Method,
urn: ServiceUrn,
}
impl RouteId {
pub fn new(method: &Method, urn: &ServiceUrn) -> Self {
Self {
method: method.clone(),
urn: urn.clone(),
}
}
}
impl Display for RouteId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}#{}", self.urn, self.method)
}
}
impl FromRequest for RouteId {
type Error = ApiErrorV1;
type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
fn from_request(req: &actix_web::HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future {
let method = req.method().clone();
let urn = req.match_name().map(|s| s.to_string());
Box::pin(async move {
urn.ok_or_else(|| ApiErrorV1::from(StatusCode::INTERNAL_SERVER_ERROR))
.and_then(|u| {
ServiceUrn::from_str(&u).map_err(|e| {
tracing::error!("Could not parse Service Urn from {u}: {e}");
ApiErrorV1::from(StatusCode::BAD_REQUEST)
})
})
.map(|svc_urn| RouteId::new(&method, &svc_urn))
})
}
}
#[cfg(test)]
mod tests {
use core::str;
use std::hash::{BuildHasher, RandomState};
use actix_web::{
App, HttpResponse, Resource, Responder,
http::{Method, StatusCode},
test::{self, TestRequest},
web::{Json, Path},
};
use super::{ResourceConfig, ResourceController, RouteId, ServiceLabel, ServiceUrn};
use crate::service::ApiService;
const METHODS: [Method; 8] = [
Method::CONNECT,
Method::GET,
Method::PATCH,
Method::POST,
Method::PUT,
Method::HEAD,
Method::TRACE,
Method::OPTIONS,
];
#[test]
fn test_service_label() {
let label = ServiceLabel::new("Tag", "foo");
assert_eq!("tag: foo", format!("{label}"));
assert_eq!(label.key(), "tag");
assert_eq!(label.value(), "foo");
}
#[test]
fn test_service_urn() {
let urn = ServiceUrn::default().push_segment("TestDataItem").unwrap();
assert_eq!(urn.to_string(), "urn:restrepo:testdataitem");
}
#[actix_web::test]
async fn test_controller_to_api_functionality() {
#[derive(Debug)]
struct TestDataController;
impl TestDataController {
const PATH: &'static str = "/test-data";
async fn fetch_collection() -> impl Responder {
HttpResponse::Ok().json("Fetching Collection")
}
async fn create_item(data: Json<String>) -> impl Responder {
HttpResponse::Created().body(format!(
"Creating Collection Item from {}",
data.into_inner()
))
}
async fn fetch_item(id: Path<i32>) -> impl Responder {
HttpResponse::Ok().body(format!("Fetching Item with ID: {id}"))
}
async fn delete_item(_id: Path<i32>) -> impl Responder {
HttpResponse::NoContent().finish()
}
}
impl ResourceController for TestDataController {
fn path(&self) -> &str {
Self::PATH
}
fn resources(&self) -> Vec<ResourceConfig> {
vec![
ResourceConfig::new("TestDataCollection")
.with_description("Controller for test data collection")
.with_route(Method::GET, Self::fetch_collection)
.with_route(Method::POST, Self::create_item),
ResourceConfig::new("TestDataItem")
.with_path("/{id}")
.with_description("Controller for test data items")
.with_route(Method::GET, Self::fetch_item)
.with_route(Method::DELETE, Self::delete_item),
]
}
}
let app = App::new().configure(|cfg| {
cfg.service(
ApiService::new("/api")
.register_controller(TestDataController)
.build(),
);
});
let server = test::init_service(app).await;
let get_collection = TestRequest::get().uri("/api/test-data").to_request();
let get_collection_resp = test::call_service(&server, get_collection).await;
assert_eq!(get_collection_resp.status(), StatusCode::OK);
let get_collection_body = test::read_body_json::<String, _>(get_collection_resp).await;
assert_eq!(get_collection_body, "Fetching Collection");
let post_collection = TestRequest::post()
.uri("/api/test-data")
.set_json("New Item")
.to_request();
let post_collection_resp = test::call_service(&server, post_collection).await;
assert_eq!(post_collection_resp.status(), StatusCode::CREATED);
let post_collection_body = test::read_body(post_collection_resp).await;
assert_eq!(
post_collection_body,
"Creating Collection Item from New Item"
);
let get_item = TestRequest::get().uri("/api/test-data/42").to_request();
let get_item_resp = test::call_service(&server, get_item).await;
assert_eq!(get_item_resp.status(), StatusCode::OK);
let get_item_body = test::read_body(get_item_resp).await;
assert_eq!(get_item_body, "Fetching Item with ID: 42");
let delete_item = TestRequest::delete().uri("/api/test-data/42").to_request();
let delete_item_resp = test::call_service(&server, delete_item).await;
assert_eq!(delete_item_resp.status(), StatusCode::NO_CONTENT);
}
#[test]
fn test_resource_config_default() {
let foo = ResourceConfig::new("Testservice");
assert_eq!(foo.name(), "Testservice");
assert_eq!(foo.path(), "");
assert!(foo.description().is_none());
assert!(foo.labels().is_empty());
assert!(foo.routes.is_empty());
}
#[test]
fn test_api_operation_hashing() {
let state = RandomState::new();
for method in METHODS {
let left = state.hash_one(RouteId::new(
&method,
&ServiceUrn::default()
.push_segments(["ops", "testroute1"])
.unwrap(),
));
let right = state.hash_one(RouteId::new(
&method,
&ServiceUrn::default()
.push_segments(["ops", "testroute1"])
.unwrap(),
));
assert_eq!(left, right);
}
}
#[test]
fn test_api_operation_printing() {
for method in METHODS {
assert_eq!(
format!("urn:restrepo:test:1234#{method}"),
format!(
"{}",
RouteId::new(
&method,
&ServiceUrn::default()
.push_segments(["test", "1234"])
.unwrap()
)
)
);
}
}
#[actix_web::test]
async fn test_route_id_from_request_impl() {
let urn = ServiceUrn::default().push_segment("foo").unwrap();
let route = Resource::new("/foo")
.name(urn.to_string().as_str())
.to(|route_id: RouteId| async move { route_id.to_string() });
let app = test::init_service(actix_web::App::new().service(route)).await;
let req = test::TestRequest::with_uri("/foo")
.method(Method::GET)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let data = test::read_body(resp).await;
assert_eq!(
data,
format!("{}", RouteId::new(&Method::GET, &urn)).as_bytes()
);
}
}