Skip to main content

pact_stub_server/
lib.rs

1//! # Standalone Pact Stub Server
2//!
3//! This project provides a server that can generate responses based on pact files. It is a single executable binary. It implements the [V4 Pact specification](https://github.com/pact-foundation/pact-specification/tree/version-4).
4//!
5//! [Online rust docs](https://docs.rs/pact-stub-server/)
6//!
7//! The stub server works by taking all the interactions (requests and responses) from a number of pact files. For each interaction, it will compare any incoming request against those defined in the pact files. If there is a match (based on method, path and query parameters), it will return the response from the pact file.
8//!
9//! ## Command line interface
10//!
11//! The pact stub server is bundled as a single binary executable `pact-stub-server`. Running this with out any options displays the standard help.
12//!
13//! ```console,ignore
14//! Pact Stub Server 0.5.2
15//!
16//! Usage: pact-stub-server [OPTIONS]
17//!
18//! Options:
19//!   -l, --loglevel <loglevel>
20//!           Log level (defaults to info) [default: info] [possible values: error, warn, info, debug, trace, none]
21//!   -f, --file <file>
22//!           Pact file to load (can be repeated)
23//!   -d, --dir <dir>
24//!           Directory of pact files to load (can be repeated)
25//!   -e, --extension <ext>
26//!           File extension to use when loading from a directory (default is json)
27//!   -u, --url <url>
28//!           URL of pact file to fetch (can be repeated)
29//!   -b, --broker-url <broker-url>
30//!           URL of the pact broker to fetch pacts from [env: PACT_BROKER_BASE_URL=]
31//!       --user <user>
32//!           User and password to use when fetching pacts from URLS or Pact Broker in user:password form
33//!   -t, --token <token>
34//!           Bearer token to use when fetching pacts from URLS or Pact Broker
35//!   -p, --port <port>
36//!           Port to run on (defaults to random port assigned by the OS)
37//!   -o, --cors
38//!           Automatically respond to OPTIONS requests and return default CORS headers
39//!       --cors-referer
40//!           Set the CORS Access-Control-Allow-Origin header to the Referer
41//!       --insecure-tls
42//!           Disables TLS certificate validation
43//!   -s, --provider-state <provider-state>
44//!           Provider state regular expression to filter the responses by
45//!       --provider-state-header-name <provider-state-header-name>
46//!           Name of the header parameter containing the provider state to be used in case multiple matching interactions are found
47//!       --empty-provider-state
48//!           Include empty provider states when filtering with --provider-state
49//!      --consumer-name <consumer-name>
50//!           Consumer name to use to filter the Pacts fetched from the Pact broker (can be repeated)
51//!       --provider-name <provider-name>
52//!           Provider name to use to filter the Pacts fetched from the Pact broker (can be repeated)
53//!   -v, --version
54//!           Print version information
55//!   -h, --help
56//!           Print help information
57//! ```
58//!
59//! ## Options
60//!
61//! ### Log Level
62//!
63//! You can control the log level with the `-l, --loglevel <loglevel>` option. It defaults to info, and the options that you can specify are: error, warn, info, debug, trace, none.
64//!
65//! ### Pact File Sources
66//!
67//! You can specify the pacts to verify with the following options. They can be repeated to set multiple sources.
68//!
69//! | Option | Type | Description |
70//! |--------|------|-------------|
71//! | `-f, --file <file>` | File | Loads a pact from the given file |
72//! | `-u, --url <url>` | URL | Loads a pact from a URL resource |
73//! | `-d, --dir <dir>` | Directory | Loads all the pacts from the given directory |
74//! | `-b, --broker-url <broker-url>` | URL | Loads all the pacts from the Pact broker |
75//!
76//! ### Server Options
77//!
78//! The running server can be controlled with the following options:
79//!
80//! | Option | Description |
81//! |--------|-------------|
82//! | `-p, --port <port>` | The port to bind to. If not specified, a random port will be allocated by the operating system. |
83//!
84
85#![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
111/// Setup file watcher for watch mode
112fn 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    // Watch all file and directory sources
139    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                // Reload pacts
158                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
195/// Extract file and directory paths from pact sources for watching
196fn 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, // URLs and Broker sources are not watchable
202    })
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/// Source for loading pacts
232#[derive(Debug, Clone)]
233pub enum PactSource {
234  /// Load the pact from a pact file
235  File(String),
236  /// Load all the pacts from a Directory
237  Dir(String),
238  /// Load the pact from a URL
239  URL(String, Option<HttpAuth>),
240  /// Load all pacts from a Pact Broker
241  Broker {
242    /// Broker URL
243    url: String,
244    /// Any required auth
245    auth: Option<HttpAuth>,
246    /// Consumer names to filter Pacts with
247    consumers: Vec<Regex>,
248    /// Provider names to filter Pacts with
249    providers: Vec<Regex>
250  },
251  /// Source that is not known, only used for unit testing
252  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
298/// Handles the command line arguments and runs the stub server accordingly.
299///
300/// Used by the binary crate. Parses the provided arguments, sets up logging, loads pact files, and starts the server.
301pub 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
321/// Handles the command line arguments and runs the stub server accordingly.
322///
323/// Used by library consumers. Creates a new Tokio runtime, handle the matches
324/// and starts the server.
325pub 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            // Currently, as_v4_pact won't fail as it upgrades older formats to V4, so is safe to unwrap
364            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          // Setup shared state for pacts when in watch mode
375          let shared_pacts = Arc::new(Mutex::new(pacts.clone()));
376          let (reload_tx, reload_rx) = broadcast::channel::<()>(1);
377          
378          // Setup file watching if in watch mode
379          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
407/// Creates a new clap Command instance with the command line arguments for the stub server.
408/// This function defines the command line interface for the stub server, including options for logging, pact file sources, and server configuration.
409pub 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;