Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions crates/routes/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,7 @@ impl Router {
}

/// Returns the constructed routes.
pub fn routes(
&self,
) -> impl Iterator<Item = (&(impl fmt::Display + fmt::Debug), &TriggerLookupKey)> {
pub fn routes(&self) -> impl Iterator<Item = (&impl RouteInfo, &TriggerLookupKey)> {
self.router
.iter()
.map(|(_spec, handler)| (&handler.parsed_based_route, &handler.lookup_key))
Expand Down Expand Up @@ -219,6 +217,14 @@ impl DuplicateRoute {
}
}

/// Information about a parsed route.
pub trait RouteInfo: fmt::Display + fmt::Debug {
/// Returns the route path without any wildcard annotation.
fn path(&self) -> &str;
/// Returns true if this route has a trailing wildcard.
fn is_wildcard(&self) -> bool;
}

#[derive(Clone, Debug)]
enum ParsedRoute {
Exact(String),
Expand All @@ -235,6 +241,24 @@ impl ParsedRoute {
}
}

impl RouteInfo for ParsedRoute {
fn path(&self) -> &str {
let p = match self {
ParsedRoute::Exact(path) => path,
ParsedRoute::TrailingWildcard(pattern) => pattern,
};
if p.is_empty() {
"/"
} else {
p
}
}

fn is_wildcard(&self) -> bool {
matches!(self, ParsedRoute::TrailingWildcard(_))
}
}

impl fmt::Display for ParsedRoute {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self {
Expand Down
2 changes: 1 addition & 1 deletion crates/runtime-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ impl<T> ResolvedRuntimeConfig<T> {
let from_path = runtime_config_path
.map(|path| format!("from {}", quoted_path(path)))
.unwrap_or_default();
println!("Using runtime config {summaries} {from_path}");
eprintln!("Using runtime config {summaries} {from_path}");
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions crates/trigger-http/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ const DEFAULT_WASIP3_MAX_INSTANCE_CONCURRENT_REUSE_COUNT: usize = 16;
const DEFAULT_REQUEST_TIMEOUT: Option<Range<Duration>> = None;
const DEFAULT_IDLE_INSTANCE_TIMEOUT: Range<Duration> = Range::Value(Duration::from_secs(1));

/// The format in which to print startup route information.
#[derive(clap::ValueEnum, Clone, Copy, Debug, Default)]
pub enum OutputFormat {
/// Human-readable plain text output (the default).
#[default]
Plain,
/// Machine-readable JSON output.
Json,
}

/// A [`spin_trigger::TriggerApp`] for the HTTP trigger.
pub(crate) type TriggerApp<F> = spin_trigger::TriggerApp<HttpTrigger, F>;

Expand Down Expand Up @@ -71,6 +81,9 @@ pub struct CliArgs {
#[clap(long = "find-free-port")]
pub find_free_port: bool,

#[clap(value_enum, long = "format", default_value_t = OutputFormat::default())]
pub format: OutputFormat,

/// Maximum number of requests to send to a single component instance before
/// dropping it.
///
Expand Down Expand Up @@ -255,6 +268,7 @@ pub struct HttpTrigger {
find_free_port: bool,
http1_max_buf_size: Option<usize>,
reuse_config: InstanceReuseConfig,
output_format: OutputFormat,
}

impl<F: RuntimeFactors> Trigger<F> for HttpTrigger {
Expand All @@ -266,6 +280,7 @@ impl<F: RuntimeFactors> Trigger<F> for HttpTrigger {
fn new(cli_args: Self::CliArgs, app: &spin_app::App) -> anyhow::Result<Self> {
let find_free_port = cli_args.find_free_port;
let http1_max_buf_size = cli_args.http1_max_buf_size;
let output_format = cli_args.format;
let reuse_config = InstanceReuseConfig {
max_instance_reuse_count: cli_args
.max_instance_reuse_count
Expand All @@ -286,6 +301,7 @@ impl<F: RuntimeFactors> Trigger<F> for HttpTrigger {
find_free_port,
http1_max_buf_size,
reuse_config,
output_format,
)
}

Expand All @@ -311,6 +327,7 @@ impl HttpTrigger {
find_free_port: bool,
http1_max_buf_size: Option<usize>,
reuse_config: InstanceReuseConfig,
output_format: OutputFormat,
) -> anyhow::Result<Self> {
Self::validate_app(app)?;

Expand All @@ -320,6 +337,7 @@ impl HttpTrigger {
find_free_port,
http1_max_buf_size,
reuse_config,
output_format,
})
}

Expand All @@ -334,6 +352,7 @@ impl HttpTrigger {
find_free_port,
http1_max_buf_size,
reuse_config,
output_format,
} = self;
let server = Arc::new(HttpServer::new(
listen_addr,
Expand All @@ -342,6 +361,7 @@ impl HttpTrigger {
trigger_app,
http1_max_buf_size,
reuse_config,
output_format,
)?);
Ok(server)
}
Expand Down
67 changes: 58 additions & 9 deletions crates/trigger-http/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use spin_http::{
app_info::AppInfo,
body,
config::{HttpExecutorType, HttpTriggerConfig},
routes::{RouteMatch, Router},
routes::{RouteInfo, RouteMatch, Router},
trigger::HandlerType,
};
use tokio::{
Expand All @@ -52,7 +52,8 @@ use crate::{
wagi::WagiHttpExecutor,
wasi::WasiHttpExecutor,
wasip3::Wasip3HttpExecutor,
Body, InstanceReuseConfig, NotFoundRouteKind, TlsConfig, TriggerApp, TriggerInstanceBuilder,
Body, InstanceReuseConfig, NotFoundRouteKind, OutputFormat, TlsConfig, TriggerApp,
TriggerInstanceBuilder,
};

pub const MAX_RETRIES: u16 = 10;
Expand All @@ -67,6 +68,8 @@ pub struct HttpServer<F: RuntimeFactors> {
http1_max_buf_size: Option<usize>,
/// Whether to find a free port if the specified port is already in use.
find_free_port: bool,
/// The output format for the server's startup information.
output_format: OutputFormat,
/// Request router.
router: Router,
/// The app being triggered.
Expand All @@ -86,6 +89,7 @@ impl<F: RuntimeFactors> HttpServer<F> {
trigger_app: TriggerApp<F>,
http1_max_buf_size: Option<usize>,
reuse_config: InstanceReuseConfig,
output_format: OutputFormat,
) -> anyhow::Result<Self> {
// This needs to be a vec before building the router to handle duplicate routes
let component_trigger_configs = trigger_app
Expand Down Expand Up @@ -154,6 +158,7 @@ impl<F: RuntimeFactors> HttpServer<F> {
http1_max_buf_size,
component_trigger_configs,
component_handler_types,
output_format,
})
}

Expand Down Expand Up @@ -566,22 +571,66 @@ impl<F: RuntimeFactors> HttpServer<F> {
.await
}

fn get_description_for_route(
&self,
key: &spin_http::routes::TriggerLookupKey,
) -> anyhow::Result<Option<String>> {
if let spin_http::routes::TriggerLookupKey::Component(component_id) = key {
self.trigger_app
.app()
.get_component(component_id)
.and_then(|c| c.get_metadata(APP_DESCRIPTION_KEY).transpose())
.transpose()
.map_err(Into::into)
} else {
Ok(None)
}
}

fn print_startup_msgs(&self, scheme: &str, listener: &TcpListener) -> anyhow::Result<()> {
let local_addr = listener.local_addr()?;
let base_url = format!("{scheme}://{local_addr:?}");
terminal::step!("\nServing", "{base_url}");
tracing::info!("Serving {base_url}");

println!("Available Routes:");
for (route, key) in self.router.routes() {
println!(" {key}: {base_url}{route}");
if let spin_http::routes::TriggerLookupKey::Component(component_id) = &key {
if let Some(component) = self.trigger_app.app().get_component(component_id) {
if let Some(description) = component.get_metadata(APP_DESCRIPTION_KEY)? {
match self.output_format {
OutputFormat::Plain => {
terminal::step!("\nServing", "{base_url}");
println!("Available Routes:");
for (route, key) in self.router.routes() {
println!(" {key}: {base_url}{route}");
if let Some(description) = self.get_description_for_route(key)? {
println!(" {description}");
}
}
}
OutputFormat::Json => {
#[derive(serde::Serialize)]
struct RoutesOutput {
base_url: String,
routes: Vec<RouteEntry>,
}
Comment thread
ChihweiLHBird marked this conversation as resolved.

#[derive(serde::Serialize)]
struct RouteEntry {
id: String,
route: String,
wildcard: bool,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
}
let mut routes = Vec::new();
for (route, key) in self.router.routes() {
routes.push(RouteEntry {
id: key.to_string(),
route: route.path().to_string(),
wildcard: route.is_wildcard(),
description: self.get_description_for_route(key)?,
});
}

let output = RoutesOutput { base_url, routes };
println!("{}", serde_json::to_string_pretty(&output)?);
}
}
Ok(())
}
Expand Down
2 changes: 1 addition & 1 deletion crates/trigger/src/cli/stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ impl<F: RuntimeFactors, U> ExecutorHooks<F, U> for StdioLoggingExecutorHooks {
Self::truncate_log_files(dir);
}

println!("Logging component stdio to {}", quoted_path(dir.join("")))
eprintln!("Logging component stdio to {}", quoted_path(dir.join("")))
}
Ok(())
}
Expand Down
4 changes: 2 additions & 2 deletions crates/trigger/src/cli/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ impl<F: RuntimeFactors, U> ExecutorHooks<F, U> for KeyValueDefaultStoreSummaryHo
return Ok(());
}
if let Some(default_store_summary) = kv_app_state.store_summary("default") {
println!("Storing default key-value data to {default_store_summary}.");
eprintln!("Storing default key-value data to {default_store_summary}.");
}
Ok(())
}
Expand Down Expand Up @@ -49,7 +49,7 @@ impl<F: RuntimeFactors, U> ExecutorHooks<F, U> for SqliteDefaultStoreSummaryHook
.and_then(Result::ok)
.and_then(|conn| conn.summary())
{
println!("Storing default SQLite data to {default_database_summary}.");
eprintln!("Storing default SQLite data to {default_database_summary}.");
}
Ok(())
}
Expand Down
69 changes: 69 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,75 @@ mod integration_tests {
Ok(())
}

#[test]
/// Test that the HTTP trigger outputs valid JSON to stdout when --format json is passed
fn http_smoke_test_json_format() -> anyhow::Result<()> {
run_test(
"http-smoke-test",
Comment thread
ChihweiLHBird marked this conversation as resolved.
SpinConfig {
binary_path: spin_binary(),
spin_up_args: vec!["--format".into(), "json".into()],
app_type: SpinAppType::Http,
},
ServicesConfig::none(),
move |env| {
let stdout = env.runtime_mut().stdout().to_owned();

let check_json = || -> anyhow::Result<()> {
let parsed: serde_json::Value =
serde_json::from_str(&stdout).context("stdout was not valid JSON")?;

let base_url = parsed["base_url"]
.as_str()
.context("JSON output missing 'base_url' string field")?;
anyhow::ensure!(
!base_url.is_empty(),
"JSON output 'base_url' field is empty"
);

let url =
url::Url::parse(base_url).context("'base_url' field is not a valid URL")?;
anyhow::ensure!(
url.scheme() == "http" || url.scheme() == "https",
"'base_url' does not have http or https scheme"
);

let routes = parsed["routes"]
.as_array()
.context("JSON output missing 'routes' array field")?;
anyhow::ensure!(
routes.len() == 1,
"Expected exactly 1 route, got {}",
routes.len()
);

let route = &routes[0];
anyhow::ensure!(
route["id"] == "hello",
"Expected route id 'hello', got: {}",
route["id"]
);
anyhow::ensure!(
route["route"] == "/hello",
"Expected route '/hello', got: {}",
route["route"]
);
anyhow::ensure!(
route["wildcard"] == true,
"Expected wildcard true, got: {}",
route["wildcard"]
);
Ok(())
};
check_json().with_context(|| format!("Actual output:\n{stdout}"))?;

Ok(())
},
)?;

Ok(())
}

#[test]
#[cfg(feature = "extern-dependencies-tests")]
#[allow(dependency_on_unit_never_type_fallback)]
Expand Down
3 changes: 2 additions & 1 deletion tests/testing-framework/src/runtimes/in_process_spin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::sync::Arc;
use anyhow::Context as _;
use spin_runtime_factors::{FactorsBuilder, TriggerAppArgs, TriggerFactors};
use spin_trigger::{cli::TriggerAppBuilder, loader::ComponentLoader};
use spin_trigger_http::{HttpServer, HttpTrigger, InstanceReuseConfig};
use spin_trigger_http::{HttpServer, HttpTrigger, InstanceReuseConfig, OutputFormat};
use test_environment::{
http::{Request, Response},
services::ServicesConfig,
Expand Down Expand Up @@ -112,6 +112,7 @@ async fn initialize_trigger(
false,
None,
InstanceReuseConfig::default(),
OutputFormat::default(),
)?;
let mut builder = TriggerAppBuilder::<_, FactorsBuilder>::new(trigger);
let trigger_app = builder
Expand Down
Loading