First import
This commit is contained in:
46
template/api/Cargo.toml
Normal file
46
template/api/Cargo.toml
Normal 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"
|
||||
8
template/api/Makefile.toml
Normal file
8
template/api/Makefile.toml
Normal 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"]
|
||||
6
template/api/config/default.toml
Normal file
6
template/api/config/default.toml
Normal 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"
|
||||
5
template/api/config/dev.toml
Normal file
5
template/api/config/dev.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[application]
|
||||
log_directive = "debug"
|
||||
static_dir = "../web/dist"
|
||||
api_path = "/api"
|
||||
static_path = ""
|
||||
4
template/api/config/prod.toml
Normal file
4
template/api/config/prod.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[application]
|
||||
static_dir = "."
|
||||
api_path = "/api"
|
||||
static_path = ""
|
||||
2
template/api/config/test.toml
Normal file
2
template/api/config/test.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[application]
|
||||
log_directive = "debug"
|
||||
48
template/api/src/configuration.rs
Normal file
48
template/api/src/configuration.rs
Normal 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
67
template/api/src/lib.rs
Normal 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
15
template/api/src/main.rs
Normal 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
|
||||
}
|
||||
23
template/api/src/observability.rs
Normal file
23
template/api/src/observability.rs
Normal 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");
|
||||
}
|
||||
28
template/api/src/routes/default.rs
Normal file
28
template/api/src/routes/default.rs
Normal 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()
|
||||
}
|
||||
5
template/api/src/routes/health_check.rs
Normal file
5
template/api/src/routes/health_check.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
pub async fn ping() -> HttpResponse {
|
||||
HttpResponse::Ok().finish()
|
||||
}
|
||||
5
template/api/src/routes/mod.rs
Normal file
5
template/api/src/routes/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod default;
|
||||
mod health_check;
|
||||
|
||||
pub use default::*;
|
||||
pub use health_check::*;
|
||||
40
template/api/src/{{project-name}}/api.rs
Normal file
40
template/api/src/{{project-name}}/api.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
3
template/api/src/{{project-name}}/mod.rs
Normal file
3
template/api/src/{{project-name}}/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod api;
|
||||
|
||||
pub use api::*;
|
||||
22
template/api/tests/api/default.rs
Normal file
22
template/api/tests/api/default.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
15
template/api/tests/api/health_check.rs
Normal file
15
template/api/tests/api/health_check.rs
Normal 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());
|
||||
}
|
||||
31
template/api/tests/api/helpers.rs
Normal file
31
template/api/tests/api/helpers.rs
Normal 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
|
||||
}
|
||||
3
template/api/tests/api/main.rs
Normal file
3
template/api/tests/api/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod default;
|
||||
mod health_check;
|
||||
mod helpers;
|
||||
Reference in New Issue
Block a user