1#![warn(missing_docs)]
86
87use std::env;
88use std::process::ExitCode;
89use std::str::FromStr;
90use std::path::PathBuf;
91use std::sync::{Arc, Mutex};
92use std::time::Duration;
93use std::sync::mpsc::channel;
94
95use clap::{Command, Arg, ArgMatches, ArgAction, command, crate_version};
96use clap::error::ErrorKind;
97use mimalloc::MiMalloc;
98use pact_models::prelude::*;
99use pact_models::prelude::v4::*;
100use regex::Regex;
101use tracing::{debug, error, info, warn};
102use tracing_core::LevelFilter;
103use tracing_subscriber::FmtSubscriber;
104use tokio::sync::broadcast;
105use notify::RecursiveMode;
106use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
107use crate::loading::load_pacts;
108
109use crate::server::ServerHandler;
110
111fn setup_file_watcher(
113 sources: Vec<PactSource>,
114 matches: &ArgMatches,
115 shared_pacts: Arc<Mutex<Vec<(V4Pact, PactSource)>>>,
116 reload_tx: broadcast::Sender<()>
117) {
118 let watch_paths = get_watch_paths(&sources);
119 if watch_paths.is_empty() {
120 warn!("No file or directory sources found for watching");
121 return;
122 }
123
124 let insecure_tls = matches.get_flag("insecure-tls");
125 let ext = matches.get_one::<String>("ext").cloned();
126 let retries = *matches.get_one::<u8>("retries").unwrap_or(&8);
127
128 std::thread::spawn(move || {
129 let (debounce_tx, debounce_rx) = channel();
130 let mut debouncer = match new_debouncer(Duration::from_secs(1), debounce_tx) {
131 Ok(debouncer) => debouncer,
132 Err(e) => {
133 error!("Failed to create file debouncer: {}", e);
134 return;
135 }
136 };
137
138 for path in &watch_paths {
140 if let Err(e) = debouncer.watcher().watch(path, RecursiveMode::Recursive) {
141 error!("Failed to watch path {:?}: {}", path, e);
142 } else {
143 info!("Watching for changes in: {:?}", path);
144 }
145 }
146
147 let runtime = tokio::runtime::Runtime::new().unwrap();
148
149 loop {
150 match debounce_rx.recv() {
151 Ok(Ok(events)) => {
152 for event in events.iter() {
153 match &event.kind {
154 DebouncedEventKind::Any => {
155 info!("File change detected in watched directory");
156
157 let pacts_result = runtime.block_on(load_pacts(sources.clone(), insecure_tls, ext.as_ref(), retries));
159 if pacts_result.iter().any(|p| p.is_err()) {
160 error!("Error reloading pacts:");
161 for error in pacts_result.iter().filter_map(|p| p.as_ref().err()) {
162 error!(" - {}", error);
163 }
164 } else {
165 let new_pacts = pacts_result.iter()
166 .filter_map(|result| result.as_ref().ok())
167 .map(|(p, s)| (p.as_v4_pact().unwrap(), s.clone()))
168 .collect::<Vec<_>>();
169
170 let interactions: usize = new_pacts.iter().map(|(p, _)| p.interactions.len()).sum();
171 info!("Reloaded {} pacts ({} total interactions)", new_pacts.len(), interactions);
172
173 *shared_pacts.lock().unwrap() = new_pacts;
174 let _ = reload_tx.send(());
175 }
176 break;
177 }
178 _ => {}
179 }
180 }
181 }
182 Ok(Err(e)) => {
183 error!("Watch error: {:?}", e);
184 break;
185 }
186 Err(e) => {
187 error!("Debouncer channel error: {:?}", e);
188 break;
189 }
190 }
191 }
192 });
193}
194
195fn get_watch_paths(sources: &[PactSource]) -> Vec<PathBuf> {
197 sources.iter()
198 .filter_map(|source| match source {
199 PactSource::File(path) => Some(PathBuf::from(path)),
200 PactSource::Dir(path) => Some(PathBuf::from(path)),
201 _ => None, })
203 .collect()
204}
205
206mod pact_support;
207mod server;
208mod loading;
209
210#[global_allocator]
211static GLOBAL: MiMalloc = MiMalloc;
212
213
214pub fn print_version() {
215 println!("pact stub server version : v{}", env!("CARGO_PKG_VERSION"));
216 println!("pact specification version: v{}", PactSpecification::V4.version_str());
217}
218
219fn integer_value(v: &str) -> Result<u16, String> {
220 v.parse::<u16>().map_err(|e| format!("'{}' is not a valid port value: {}", v, e) )
221}
222
223fn regex_value(v: &str) -> Result<Regex, String> {
224 if v.is_empty() {
225 Err("Regular expression is empty".to_string())
226 } else {
227 Regex::new(v).map_err(|e| format!("'{}' is not a valid regular expression: {}", v, e))
228 }
229}
230
231#[derive(Debug, Clone)]
233pub enum PactSource {
234 File(String),
236 Dir(String),
238 URL(String, Option<HttpAuth>),
240 Broker {
242 url: String,
244 auth: Option<HttpAuth>,
246 consumers: Vec<Regex>,
248 providers: Vec<Regex>
250 },
251 Unknown
253}
254
255fn pact_source(matches: &ArgMatches) -> Vec<PactSource> {
256 let mut sources = vec![];
257
258 if let Some(values) = matches.get_many::<String>("file") {
259 sources.extend(values.map(|v| PactSource::File(v.clone())).collect::<Vec<PactSource>>());
260 }
261
262 if let Some(values) = matches.get_many::<String>("dir") {
263 sources.extend(values.map(|v| PactSource::Dir(v.clone())).collect::<Vec<PactSource>>());
264 }
265
266 if let Some(values) = matches.get_many::<String>("url") {
267 sources.extend(values.map(|v| {
268 let auth = matches.get_one::<String>("user")
269 .map(|u| {
270 let mut auth = u.split(':');
271 HttpAuth::User(auth.next().unwrap().to_string(), auth.next().map(|p| p.to_string()))
272 })
273 .or_else(|| matches.get_one::<String>("token").map(|v| HttpAuth::Token(v.clone())));
274 PactSource::URL(v.clone(), auth)
275 }).collect::<Vec<PactSource>>());
276 }
277
278 if let Some(url) = matches.get_one::<String>("broker-url") {
279 let auth = matches.get_one::<String>("user")
280 .map(|u| {
281 let mut auth = u.split(':');
282 HttpAuth::User(auth.next().unwrap().to_string(), auth.next().map(|p| p.to_string()))
283 })
284 .or_else(|| matches.get_one::<String>("token").map(|v| HttpAuth::Token(v.clone())));
285 debug!("Loading all pacts from Pact Broker at {} using {} authentication", url,
286 auth.clone().map(|auth| auth.to_string()).unwrap_or_else(|| "no".to_string()));
287 sources.push(PactSource::Broker {
288 url: url.to_string(),
289 auth,
290 consumers: matches.get_many::<Regex>("consumer-name").unwrap_or_default().into_iter().cloned().collect(),
291 providers: matches.get_many::<Regex>("provider-name").unwrap_or_default().into_iter().cloned().collect()
292 });
293 }
294
295 sources
296}
297
298pub async fn handle_command_args(args: Vec<String>) -> Result<(), ExitCode> {
302 let app = build_args();
303 match app.try_get_matches_from(args) {
304 Ok(results) => handle_matches(&results).await,
305
306 Err(ref err) => match err.kind() {
307 ErrorKind::DisplayHelp => {
308 println!("{}", err);
309 Ok(())
310 }
311 ErrorKind::DisplayVersion => {
312 print_version();
313 println!();
314 Ok(())
315 }
316 _ => err.exit(),
317 },
318 }
319}
320
321pub fn process_stub_command(args: &ArgMatches) -> Result<(), ExitCode> {
326 tokio::runtime::Runtime::new().unwrap().block_on(async {
327 let res = handle_matches(args).await;
328 match res {
329 Ok(()) => Ok(()),
330 Err(code) => Err(code),
331 }
332 })
333}
334
335async fn handle_matches(matches: &ArgMatches) -> Result<(), ExitCode> {
336 let level = matches.get_one::<String>("loglevel").cloned()
337 .unwrap_or_else(|| "info".to_string());
338 setup_logger(level.as_str());
339 let sources = pact_source(matches);
340 let watch_mode = matches.get_flag("watch");
341
342 let retries = *matches.get_one::<u8>("retries").unwrap_or(&8);
343 let pacts = load_pacts(sources.clone(), matches.get_flag("insecure-tls"),
344 matches.get_one("ext"), retries).await;
345 if pacts.iter().any(|p| p.is_err()) {
346 error!("There were errors loading the pact files.");
347 for error in pacts.iter()
348 .filter(|p| p.is_err())
349 .map(|e| match e {
350 Err(err) => err.clone(),
351 _ => panic!("Internal Code Error - was expecting an error but was not")
352 }) {
353 error!(" - {}", error);
354 }
355 Err(ExitCode::from(3))
356 } else {
357 let port = *matches.get_one::<u16>("port").unwrap_or(&0);
358 let provider_state = matches.get_one::<Regex>("provider-state").cloned();
359 let provider_state_header_name = matches.get_one::<String>("provider-state-header-name").cloned();
360 let empty_provider_states = matches.get_flag("empty-provider-state");
361 let pacts = pacts.iter()
362 .map(|result| {
363 let (p, s) = result.as_ref().unwrap();
365 (p.as_v4_pact().unwrap(), s.clone())
366 })
367 .collect::<Vec<_>>();
368 let interactions: usize = pacts.iter().map(|(p, _)| p.interactions.len()).sum();
369 info!("Loaded {} pacts ({} total interactions)", pacts.len(), interactions);
370 let auto_cors = matches.get_flag("cors");
371 let referer = matches.get_flag("cors-referer");
372
373 if watch_mode {
374 let shared_pacts = Arc::new(Mutex::new(pacts.clone()));
376 let (reload_tx, reload_rx) = broadcast::channel::<()>(1);
377
378 setup_file_watcher(sources, matches, shared_pacts.clone(), reload_tx.clone());
380
381 let server_handler = ServerHandler::new_with_watch(
382 shared_pacts,
383 reload_tx,
384 auto_cors,
385 referer,
386 provider_state,
387 provider_state_header_name,
388 empty_provider_states);
389 tokio::task::spawn_blocking(move || {
390 server_handler.start_server(port)
391 }).await.unwrap()
392 } else {
393 let server_handler = ServerHandler::new(
394 pacts,
395 auto_cors,
396 referer,
397 provider_state,
398 provider_state_header_name,
399 empty_provider_states);
400 tokio::task::spawn_blocking(move || {
401 server_handler.start_server(port)
402 }).await.unwrap()
403 }
404 }
405}
406
407pub fn build_args() -> Command {
410 command!()
411 .about(format!("Pact Stub Server {}", crate_version!()))
412 .arg_required_else_help(true)
413 .disable_version_flag(true)
414 .arg(Arg::new("loglevel")
415 .short('l')
416 .long("loglevel")
417 .default_value("info")
418 .value_parser(["error", "warn", "info", "debug", "trace", "none"])
419 .help("Log level (defaults to info)"))
420 .arg(Arg::new("file")
421 .short('f')
422 .long("file")
423 .required_unless_present_any(&["dir", "url", "broker-url"])
424 .action(ArgAction::Append)
425 .value_parser(clap::builder::NonEmptyStringValueParser::new())
426 .help("Pact file to load (can be repeated)"))
427 .arg(Arg::new("dir")
428 .short('d')
429 .long("dir")
430 .required_unless_present_any(&["file", "url", "broker-url"])
431 .action(ArgAction::Append)
432 .value_parser(clap::builder::NonEmptyStringValueParser::new())
433 .help("Directory of pact files to load (can be repeated)"))
434 .arg(Arg::new("ext")
435 .short('e')
436 .long("extension")
437 .value_parser(clap::builder::NonEmptyStringValueParser::new())
438 .requires("dir")
439 .help("File extension to use when loading from a directory (default is json)"))
440 .arg(Arg::new("url")
441 .short('u')
442 .long("url")
443 .required_unless_present_any(&["file", "dir", "broker-url"])
444 .action(ArgAction::Append)
445 .value_parser(clap::builder::NonEmptyStringValueParser::new())
446 .help("URL of pact file to fetch (can be repeated)"))
447 .arg(Arg::new("broker-url")
448 .short('b')
449 .long("broker-url")
450 .env("PACT_BROKER_BASE_URL")
451 .required_unless_present_any(&["file", "dir", "url"])
452 .value_parser(clap::builder::NonEmptyStringValueParser::new())
453 .help("URL of the pact broker to fetch pacts from"))
454 .arg(Arg::new("user")
455 .long("user")
456 .value_parser(clap::builder::NonEmptyStringValueParser::new())
457 .conflicts_with("token")
458 .help("User and password to use when fetching pacts from URLS or Pact Broker in user:password form"))
459 .arg(Arg::new("token")
460 .short('t')
461 .long("token")
462 .value_parser(clap::builder::NonEmptyStringValueParser::new())
463 .conflicts_with("user")
464 .help("Bearer token to use when fetching pacts from URLS or Pact Broker"))
465 .arg(Arg::new("port")
466 .short('p')
467 .long("port")
468 .use_value_delimiter(false)
469 .help("Port to run on (defaults to random port assigned by the OS)")
470 .value_parser(integer_value))
471 .arg(Arg::new("cors")
472 .short('o')
473 .long("cors")
474 .action(ArgAction::SetTrue)
475 .help("Automatically respond to OPTIONS requests and return default CORS headers"))
476 .arg(Arg::new("cors-referer")
477 .long("cors-referer")
478 .requires("cors")
479 .action(ArgAction::SetTrue)
480 .help("Set the CORS Access-Control-Allow-Origin header to the Referer"))
481 .arg(Arg::new("insecure-tls")
482 .long("insecure-tls")
483 .action(ArgAction::SetTrue)
484 .help("Disables TLS certificate validation"))
485 .arg(Arg::new("provider-state")
486 .short('s')
487 .long("provider-state")
488 .value_parser(regex_value)
489 .help("Provider state regular expression to filter the responses by"))
490 .arg(Arg::new("provider-state-header-name")
491 .long("provider-state-header-name")
492 .value_parser(clap::builder::NonEmptyStringValueParser::new())
493 .help("Name of the header parameter containing the provider state to be used in case \
494 multiple matching interactions are found"))
495 .arg(Arg::new("empty-provider-state")
496 .long("empty-provider-state")
497 .requires("provider-state")
498 .action(ArgAction::SetTrue)
499 .help("Include empty provider states when filtering with --provider-state"))
500 .arg(Arg::new("consumer-name")
501 .long("consumer-name")
502 .alias("consumer-names")
503 .requires("broker-url")
504 .action(ArgAction::Append)
505 .value_parser(regex_value)
506 .help("Consumer name or regex to use to filter the Pacts fetched from the Pact broker (can be repeated)"))
507 .arg(Arg::new("provider-name")
508 .long("provider-name")
509 .alias("provider-names")
510 .requires("broker-url")
511 .action(ArgAction::Append)
512 .value_parser(regex_value)
513 .help("Provider name or regex to use to filter the Pacts fetched from the Pact broker (can be repeated)"))
514 .arg(Arg::new("retries")
515 .long("retries")
516 .num_args(1)
517 .default_value("8")
518 .value_parser(clap::value_parser!(u8))
519 .help("The number of times to retry failed HTTP requests (retries on 5xx, 408, and 429). Delays use exponential back-off starting at 500 ms and doubling each attempt.")
520 .value_name("PACT_BROKER_HTTP_RETRIES")
521 .env("PACT_BROKER_HTTP_RETRIES"))
522 .arg(Arg::new("watch")
523 .short('w')
524 .long("watch")
525 .action(ArgAction::SetTrue)
526 .help("Watch for changes in pact files and reload automatically"))
527 .arg(Arg::new("version")
528 .short('v')
529 .long("version")
530 .action(ArgAction::Version)
531 .help("Print version information"))
532}
533
534fn setup_logger(level: &str) {
535 let log_level = match level {
536 "none" => LevelFilter::OFF,
537 _ => LevelFilter::from_str(level).unwrap_or(LevelFilter::INFO)
538 };
539 let subscriber = FmtSubscriber::builder()
540 .compact()
541 .with_max_level(log_level)
542 .with_thread_names(true)
543 .finish();
544 if let Err(err) = tracing::subscriber::set_global_default(subscriber) {
545 eprintln!("ERROR: Failed to initialise global tracing subscriber - {err}");
546 };
547}
548
549#[cfg(test)]
550mod test;