First import

This commit is contained in:
2022-04-05 23:00:18 +02:00
commit f7aa21e338
46 changed files with 1621 additions and 0 deletions

46
template/api/Cargo.toml Normal file
View File

@@ -0,0 +1,46 @@
[package]
name = "{{project-name}}-api"
version = "0.1.0"
edition = "2021"
authors = ["{{authors}}"]
[lib]
path = "src/lib.rs"
[[bin]]
path = "src/main.rs"
name = "{{project-name}}-api"
[dependencies]
{{project-name}} = { path = ".." }
actix-web = "4.0.0"
actix-http = "3.0.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1.0.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "0.8.0", features = ["serde"] }
chrono = { version = "0.4.0", features = ["serde"] }
mktemp = "0.4.0"
configparser = "3.0.0"
tracing = { version = "0.1.0", features = ["log"] }
tracing-subscriber = { version = "0.3.0", features = [
"std",
"env-filter",
"fmt",
"json",
] }
tracing-log = "0.1.0"
tracing-actix-web = "0.5.0"
regex = "1.5.0"
lazy_static = "1.4.0"
tracing-bunyan-formatter = "0.3.0"
thiserror = "1.0"
anyhow = "1.0"
http = "0.2.0"
config = "0.12.0"
actix-files = "0.6.0"
[dev-dependencies]
proptest = "1.0.0"
reqwest = { version = "0.11.0", features = ["json"] }
rstest = "0.12.0"

View File

@@ -0,0 +1,8 @@
extend = "../Makefile.toml"
[tasks.run]
clear = true
install_crate = { crate_name = "bunyan", binary = "bunyan" }
env = { "TASKRC" = "$PWD/taskrc" }
command = "bash"
args = ["-c", "cargo run | bunyan"]

View File

@@ -0,0 +1,6 @@
[application]
port = 8000
# See https://docs.rs/tracing-subscriber/latest/tracing_subscriber/struct.EnvFilter.html
log_directive = "info"
api_path = ""
front_base_url = "http://localhost:8080"

View File

@@ -0,0 +1,5 @@
[application]
log_directive = "debug"
static_dir = "../web/dist"
api_path = "/api"
static_path = ""

View File

@@ -0,0 +1,4 @@
[application]
static_dir = "."
api_path = "/api"
static_path = ""

View File

@@ -0,0 +1,2 @@
[application]
log_directive = "debug"

View File

@@ -0,0 +1,48 @@
use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;
use std::env;
#[derive(Deserialize)]
pub struct Settings {
pub application: ApplicationSettings,
}
#[derive(Deserialize)]
pub struct ApplicationSettings {
pub port: u16,
pub log_directive: String,
pub front_base_url: String,
pub api_path: String,
pub static_path: Option<String>,
pub static_dir: Option<String>,
}
impl Settings {
pub fn new_from_file(file: Option<String>) -> Result<Self, ConfigError> {
let config_file_required = file.is_some();
let config_path = env::var("CONFIG_PATH").unwrap_or_else(|_| "config".into());
let config_file = file.unwrap_or_else(|| {
env::var("CONFIG_FILE").unwrap_or_else(|_| format!("{}/dev", &config_path))
});
let default_config_file = format!("{}/default", config_path);
let local_config_file = format!("{}/local", config_path);
println!(
"Trying to load {:?} config files",
vec![&default_config_file, &local_config_file, &config_file]
);
let config = Config::builder()
.add_source(File::with_name(&default_config_file))
.add_source(File::with_name(&local_config_file).required(false))
.add_source(File::with_name(&config_file).required(config_file_required))
.add_source(Environment::with_prefix("{{crate_name}}"))
.build()?;
config.try_deserialize()
}
pub fn new() -> Result<Self, ConfigError> {
Settings::new_from_file(None)
}
}

67
template/api/src/lib.rs Normal file
View File

@@ -0,0 +1,67 @@
use actix_files as fs;
use actix_web::{dev::Server, http, middleware, web, App, HttpServer};
use configuration::Settings;
use core::time::Duration;
use std::net::TcpListener;
use tracing::info;
use tracing_actix_web::TracingLogger;
pub mod configuration;
pub mod {{crate_name}};
pub mod observability;
pub mod routes;
pub fn run(listener: TcpListener, settings: &Settings) -> Result<Server, std::io::Error> {
let api_path = settings.application.api_path.clone();
let front_base_url = settings.application.front_base_url.clone();
let static_path = settings.application.static_path.clone();
let static_dir = settings
.application
.static_dir
.clone()
.unwrap_or_else(|| ".".to_string());
let server = HttpServer::new(move || {
info!(
"Mounting API on {}",
if api_path.is_empty() { "/" } else { &api_path }
);
let api_scope = web::scope(&api_path)
.wrap(
middleware::DefaultHeaders::new()
.add(("Access-Control-Allow-Origin", front_base_url.as_bytes()))
.add((
"Access-Control-Allow-Methods",
"POST, GET, OPTIONS".as_bytes(),
))
.add(("Access-Control-Allow-Headers", "content-type".as_bytes())),
)
.route("/hello", web::get().to(routes::hello))
.route(
"/TODO*",
web::method(http::Method::OPTIONS).to(routes::option_wildcard),
);
let mut app = App::new()
.wrap(TracingLogger::default())
.wrap(middleware::Compress::default())
.route("/ping", web::get().to(routes::ping))
.service(api_scope);
if let Some(path) = &static_path {
info!(
"Mounting static files on {}",
if path.is_empty() { "/" } else { &path }
);
let static_scope = fs::Files::new(path, &static_dir)
.use_last_modified(true)
.index_file("index.html");
app = app.service(static_scope);
}
app
})
.keep_alive(http::KeepAlive::Timeout(Duration::from_secs(60)))
.shutdown_timeout(60)
.listen(listener)?;
Ok(server.run())
}

15
template/api/src/main.rs Normal file
View File

@@ -0,0 +1,15 @@
use {{crate_name}}_api::configuration::Settings;
use {{crate_name}}_api::observability::{get_subscriber, init_subscriber};
use {{crate_name}}_api::run;
use std::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let settings = Settings::new().expect("Cannot load {{project-name | capitalize}} configuration");
let subscriber = get_subscriber(&settings.application.log_directive);
init_subscriber(subscriber);
let listener = TcpListener::bind(format!("0.0.0.0:{}", settings.application.port))
.expect("Failed to bind port");
run(listener, &settings)?.await
}

View File

@@ -0,0 +1,23 @@
use tracing::{subscriber::set_global_default, Subscriber};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
use tracing_subscriber::fmt::TestWriter;
use tracing_subscriber::{layer::SubscriberExt, EnvFilter};
pub fn get_subscriber(env_filter_str: &str) -> impl Subscriber + Send + Sync {
let formatting_layer =
BunyanFormattingLayer::new("{{project-name}}-api".into(), TestWriter::new);
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter_str));
tracing_subscriber::registry()
.with(env_filter)
.with(JsonStorageLayer)
.with(formatting_layer)
}
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
LogTracer::init().expect("Failed to set logger");
set_global_default(subscriber).expect("Failed to set subscriber");
}

View File

@@ -0,0 +1,28 @@
use crate::{{crate_name}};
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
use anyhow::Context;
use ::{{crate_name}}::Object;
impl ResponseError for {{crate_name}}::{{project-name | capitalize}}Error {
fn status_code(&self) -> StatusCode {
match self {
{{crate_name}}::{{project-name | capitalize}}Error::InvalidDataError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
{{crate_name}}::{{project-name | capitalize}}Error::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
#[tracing::instrument(level = "debug")]
pub async fn hello() -> Result<HttpResponse, {{crate_name}}::{{project-name | capitalize}}Error> {
let object: Object = {{crate_name}}::build_object()?;
Ok(HttpResponse::Ok()
.content_type("application/json")
.body(serde_json::to_string(&object).context("Cannot serialize object")?))
}
#[tracing::instrument(level = "debug")]
pub async fn option_wildcard() -> HttpResponse {
HttpResponse::Ok().finish()
}

View File

@@ -0,0 +1,5 @@
use actix_web::HttpResponse;
pub async fn ping() -> HttpResponse {
HttpResponse::Ok().finish()
}

View File

@@ -0,0 +1,5 @@
mod default;
mod health_check;
pub use default::*;
pub use health_check::*;

View File

@@ -0,0 +1,40 @@
use chrono::Utc;
use serde_json;
use {{crate_name}}::Object;
use uuid::Uuid;
fn error_chain_fmt(
e: &impl std::error::Error,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
writeln!(f, "{}\n", e)?;
let mut current = e.source();
while let Some(cause) = current {
writeln!(f, "Caused by:\n\t{}", cause)?;
current = cause.source();
}
Ok(())
}
impl std::fmt::Debug for {{project-name | capitalize}}Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
error_chain_fmt(self, f)
}
}
#[derive(thiserror::Error)]
pub enum {{project-name | capitalize}}Error {
#[error("Invalid {{project-name | capitalize}} data")]
InvalidDataError(#[from] serde_json::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
#[tracing::instrument(level = "debug")]
pub fn build_object() -> Result<Object, {{project-name | capitalize}}Error> {
Ok(Object {
name: "test".to_string(),
id: Uuid::new_v4(),
modified: Utc::now(),
})
}

View File

@@ -0,0 +1,3 @@
mod api;
pub use api::*;

View File

@@ -0,0 +1,22 @@
use crate::helpers::app_address;
use ::{{crate_name}}::Object;
use rstest::*;
mod get_object {
use super::*;
#[rstest]
#[tokio::test]
async fn test_hello(app_address: &str) {
let object: Object = reqwest::Client::new()
.get(&format!("{}/hello", &app_address))
.send()
.await
.expect("Failed to execute request")
.json()
.await
.expect("Cannot parse JSON result");
assert_eq!(object.name, "test");
}
}

View File

@@ -0,0 +1,15 @@
use crate::helpers::app_address;
use rstest::*;
#[rstest]
#[tokio::test]
async fn health_check_works(app_address: &str) {
let response = reqwest::Client::new()
.get(&format!("{}/ping", &app_address))
.send()
.await
.expect("Failed to execute request.");
assert!(response.status().is_success());
assert_eq!(Some(0), response.content_length());
}

View File

@@ -0,0 +1,31 @@
use {{crate_name}}_api::configuration::Settings;
use {{crate_name}}_api::observability::{get_subscriber, init_subscriber};
use rstest::*;
use std::net::TcpListener;
use tracing::info;
fn setup_tracing(settings: &Settings) {
info!("Setting up tracing");
let subscriber = get_subscriber(&settings.application.log_directive);
init_subscriber(subscriber);
}
fn setup_server(settings: &Settings) -> String {
info!("Setting up server");
let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
let port = listener.local_addr().unwrap().port();
let server = {{crate_name}}_api::run(listener, settings).expect("Failed to bind address");
let _ = tokio::spawn(server);
format!("http://127.0.0.1:{}", port)
}
#[fixture]
#[once]
pub fn app_address() -> String {
let settings = Settings::new_from_file(Some("config/test".to_string()))
.expect("Cannot load test configuration");
setup_tracing(&settings);
let address = setup_server(&settings);
address
}

View File

@@ -0,0 +1,3 @@
mod default;
mod health_check;
mod helpers;