apimock 5.18.0

HTTP(S) mock server. Drop JSON files into a folder and your API immediately exists.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
use std::{env, fs, io, path::Path};

pub mod constant;
pub mod init_interactive;

use constant::*;

use anyhow::{Result as AppResult, bail};

/// CLI arguments parsed at process start-up.
///
/// # Why these three fields are the only command-line surface
///
/// `apimock` deliberately keeps its CLI tiny: config-file path, port,
/// fallback respond dir. Anything richer than that belongs in the TOML
/// config so that it can be checked in with the rest of the mock setup
/// and reproduced between machines. The three CLI flags exist only for
/// quick ad-hoc overrides that don't warrant editing the config file.
#[derive(Clone)]
pub struct EnvArgs {
    /// path to the config TOML file (usually `./apimock.toml`)
    pub config_file_path: Option<String>,
    /// overrides `listener.port` in the config file
    pub port: Option<u16>,
    /// overrides `service.fallback_respond_dir` in the config file
    pub fallback_respond_dir_path: Option<String>,
}

impl EnvArgs {
    /// Parse `env::args()` and apply defaults.
    ///
    /// Returns:
    /// - `Ok(Some(args))` for the normal "start the server" path,
    /// - `Ok(None)` when a meta command (e.g. `--init`) has already
    ///   completed its side effect and the process should exit cleanly,
    /// - `Err(_)` when an argument was malformed or a referenced file
    ///   is missing.
    ///
    /// # Why return `AppResult<Option<_>>` instead of panicking
    ///
    /// Previously invalid arguments triggered `panic!`, which printed a
    /// backtrace for a user-level error. Returning a typed error lets the
    /// binary print "invalid port: foo" and exit 1, which is what users
    /// of CLI tools actually expect.
    // clippy: renaming `default` would change apimock::args::EnvArgs's
    // public API surface; this is a fallible constructor, not the
    // std::default::Default trait's method.
    #[allow(clippy::should_implement_trait)]
    pub fn default() -> AppResult<Option<Self>> {
        let raw: Vec<String> = env::args().collect();

        // `--version` / `--help` short-circuit before anything else -
        // before a config file is read and before any listener binds
        // (RFC 049 Goals 2/3). This must work in a directory with no
        // config file, and in one with a deliberately broken config:
        // "what version am I running" is asked precisely when something
        // is wrong, so it can't depend on config loading having
        // succeeded. `--help` is reachable per subcommand too - the
        // subcommand name (if any) is `raw.get(1)`.
        if any_present(&raw, VERSION_OPTION_NAMES.as_ref()) {
            println!("apimock {}", env!("CARGO_PKG_VERSION"));
            std::process::exit(0);
        }
        if any_present(&raw, HELP_OPTION_NAMES.as_ref()) {
            println!("{}", help_text(raw.get(1).map(String::as_str)));
            std::process::exit(0);
        }

        // `apimock match-test …` — dry-run rule matching.
        if raw.get(1).map(String::as_str) == Some("match-test") {
            crate::cmd::match_test::run(&raw[2..])?;
            return Ok(None);
        }

        // `apimock validate …` — validate config without starting the server.
        if raw.get(1).map(String::as_str) == Some("validate") {
            std::process::exit(crate::cmd::validate::run(&raw[2..]));
        }

        // RFC 049 Goal 1: anything left that looks like a flag and isn't
        // one of the top-level names above is unrecognised and must
        // error, not be silently discarded. Applied before `--init`
        // branches so both the "start the server" and "--init" surfaces
        // get the same treatment - the defect this closes isn't
        // specific to either.
        reject_unknown_arguments(&raw);

        let mut ret = EnvArgs::from_args()?;

        let init_config = args_option_value(INIT_CONFIG_OPTION_NAMES.as_ref()).is_some();
        if init_config {
            let includes_middleware =
                args_option_value(INCLUDES_MIDDLEWARE_OPTION_NAMES.as_ref()).is_some();
            let force_defaults = args_option_value(YES_OPTION_NAMES.as_ref()).is_some();
            // Drive the interactive prompt (or fall back to defaults in
            // non-TTY / --yes contexts). We log but don't propagate the
            // error: a failed init is a user-level problem, and forcing
            // the binary to exit 1 on a partial write would be more
            // disruptive than informative.
            if let Err(err) = ret.init_config_interactive(includes_middleware, force_defaults) {
                log::error!("failed to init config ({})", err);
            }
            return Ok(None);
        }

        ret.default_config_file_path();
        ret.validate()?;

        Ok(Some(ret))
    }

    /// Ensure paths referenced by CLI flags actually exist.
    ///
    /// We only check existence, not permission or content — a file the
    /// process can see but can't read will still produce a better error
    /// downstream at the point it's actually used.
    pub fn validate(&self) -> AppResult<()> {
        if let Some(config_file_path) = self.config_file_path.as_ref()
            && !Path::new(config_file_path.as_str()).exists()
        {
            bail!(
                "config file specified via --config does not exist: {}",
                config_file_path
            );
        }

        if let Some(fallback_respond_dir_path) = self.fallback_respond_dir_path.as_ref()
            && !Path::new(fallback_respond_dir_path.as_str()).exists()
        {
            bail!(
                "fallback response dir specified via --dir does not exist: {}",
                fallback_respond_dir_path
            );
        }

        Ok(())
    }

    /// Build an `EnvArgs` by reading `env::args()`.
    fn from_args() -> AppResult<Self> {
        // RFC 049: an invalid --port value is a usage error (exit 2), not
        // "everything else" (exit 1) - it's caught before any config is
        // read or listener bound, same as an unknown flag.
        let port = args_option_value(CONFIG_LISTENER_PORT_OPTION_NAMES.as_ref()).map(|port_str| {
            port_str.parse::<u16>().unwrap_or_else(|_| {
                exit_usage_error(&format!("invalid value for --port: '{}'", port_str))
            })
        });

        Ok(EnvArgs {
            // RFC 049 Goal 4: a bare relative `--config apimock.toml`
            // must resolve the same as `--config ./apimock.toml`. The
            // actual defect is downstream, in
            // `apimock_config::path_util` (`Path::parent()` returns
            // `Some("")` for a bare filename, not `None`, and
            // canonicalizing "" fails) - but this RFC's scope is the CLI
            // surface only, so the fix is applied to the input here,
            // before it ever reaches config loading, rather than to
            // config loading itself.
            config_file_path: args_option_value(CONFIG_FILE_PATH_OPTION_NAMES.as_ref())
                .map(normalize_bare_relative_path),
            port,
            fallback_respond_dir_path: args_option_value(
                FALLBACK_RESPOND_DIR_PATH_OPTION_NAMES.as_ref(),
            ),
        })
    }

    /// Scaffold `apimock.toml` (and related files) into the current directory,
    /// driven by interactive prompts when stdin is a TTY.
    ///
    /// Files that already exist are left untouched — `--init` is a
    /// convenience for fresh directories, not an overwrite tool.
    ///
    /// # Why this never returns an error for an existing config file
    ///
    /// If the operator already has an `apimock.toml`, bailing out with a
    /// non-zero exit would break repeatable idempotent scripts that run
    /// `--init` before starting the server. Printing a warning and
    /// continuing preserves that usage pattern.
    fn init_config_interactive(
        &mut self,
        cli_middleware_override: bool,
        force_defaults: bool,
    ) -> Result<(), io::Error> {
        // Early exit if the root config already exists — we never overwrite
        // it, and asking a barrage of questions we're about to ignore would
        // waste the user's time.
        if Path::new(DEFAULT_CONFIG_FILE_PATH).exists() {
            println!(
                "[warn] quit because default root config file exists: {}.",
                DEFAULT_CONFIG_FILE_PATH
            );
            return Ok(());
        }

        let answers = init_interactive::run(force_defaults, cli_middleware_override)?;

        // Middleware file — honours both the CLI flag and the interactive answer.
        if answers.include_middleware {
            if !Path::new(DEFAULT_MIDDLEWARE_FILE_PATH).exists() {
                let content = include_str!("../examples/config/default/apimock-middleware.rhai");
                fs::write(DEFAULT_MIDDLEWARE_FILE_PATH, content)?;
                println!(
                    "middleware scripting file is created: {}.",
                    DEFAULT_MIDDLEWARE_FILE_PATH
                );
            } else {
                println!(
                    "[warn] middleware scripting file exists: {}.",
                    DEFAULT_MIDDLEWARE_FILE_PATH
                );
            }
        }

        // Root config — templated from the collected answers so the file
        // reflects the user's actual choices rather than a fixed example.
        let config_content = init_interactive::render_apimock_toml(&answers);
        fs::write(DEFAULT_CONFIG_FILE_PATH, config_content)?;
        println!("root config file is created: {}.", DEFAULT_CONFIG_FILE_PATH);

        // Rule set file — still the example content, because customising
        // rule shapes interactively would be a much larger prompt tree
        // for diminishing value. Users are expected to edit this file.
        if answers.include_rule_set && !Path::new(DEFAULT_RULE_SET_FILE_PATH).exists() {
            let rule_set_content = include_str!("../examples/config/default/apimock-rule-set.toml");
            fs::write(DEFAULT_RULE_SET_FILE_PATH, rule_set_content)?;
            println!(
                "rule set config file is created: {}.",
                DEFAULT_RULE_SET_FILE_PATH
            );
        }

        init_interactive::print_summary(&answers);
        Ok(())
    }

    /// If no config file was specified on the command line and one exists
    /// at `./apimock.toml`, use that.
    ///
    /// This is what powers the "run `apimock` in your project directory
    /// and it just picks up the config" behaviour.
    fn default_config_file_path(&mut self) {
        if self.config_file_path.is_some() {
            return;
        }
        if !Path::new(DEFAULT_CONFIG_FILE_PATH).exists() {
            return;
        }
        self.config_file_path = Some(DEFAULT_CONFIG_FILE_PATH.to_owned());
    }
}

/// Look up the value associated with any of the given option names in
/// `env::args()`.
///
/// For flags that don't take a value (e.g. `--init`), returns `Some("")`
/// so the caller can check `.is_some()` without caring about the payload.
fn args_option_value(option_names: &[&str]) -> Option<String> {
    let args: Vec<String> = env::args().collect();

    let name_index = args
        .iter()
        .position(|arg| option_names.contains(&arg.as_str()))?;

    let name_value = args.get(name_index + 1);
    match name_value {
        Some(v) if !v.starts_with('-') => Some(v.to_owned()),
        _ => Some(String::new()),
    }
}

/// True if any of `raw` matches one of `option_names` exactly.
fn any_present(raw: &[String], option_names: &[&str]) -> bool {
    raw.iter().any(|arg| option_names.contains(&arg.as_str()))
}

/// Print a usage-error message to stderr and exit 2 (RFC 049: "usage
/// error - unknown option, missing or invalid value"), without
/// producing any output on stdout and without starting a server.
fn exit_usage_error(message: &str) -> ! {
    eprintln!("apimock: {}", message);
    std::process::exit(2);
}

/// RFC 049 Goal 1: after every known top-level flag name is accounted
/// for, anything left that looks like a flag (starts with `-`) is
/// unrecognised. Exits 2 naming the offender, with a near-match
/// suggestion where one exists - the difference between a dead end and
/// a self-correction, for a person and for an agent alike.
///
/// Positional values consumed by a known flag (e.g. the number after
/// `-p`) are skipped along with that flag, using the exact same
/// "does the next token start with `-`" rule `args_option_value` uses,
/// so this never disagrees with how a flag's value actually gets read.
fn reject_unknown_arguments(raw: &[String]) {
    let known = KNOWN_TOP_LEVEL_OPTION_NAMES.as_ref();
    let mut skip_next = false;
    for (i, arg) in raw.iter().enumerate() {
        if i == 0 {
            continue; // argv[0]: the binary path itself
        }
        if skip_next {
            skip_next = false;
            continue;
        }
        if arg == "match-test" || arg == "validate" {
            // Positional subcommand names are handled by their own
            // caller before this runs; reaching here at all means
            // neither matched, so nothing to do with them here either.
            continue;
        }
        if known.contains(&arg.as_str()) {
            let next_is_value = raw.get(i + 1).is_some_and(|next| !next.starts_with('-'));
            if next_is_value {
                skip_next = true;
            }
            continue;
        }
        if arg.starts_with('-') {
            match near_match(arg, known) {
                Some(suggestion) => exit_usage_error(&format!(
                    "unknown option '{}'; did you mean '{}'?",
                    arg, suggestion
                )),
                None => exit_usage_error(&format!("unknown option '{}'", arg)),
            }
        }
    }
}

/// Find the closest known flag to an unrecognised one, if the edit
/// distance is small enough relative to length to be a plausible typo
/// rather than an unrelated word.
fn near_match<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
    known
        .iter()
        .map(|&candidate| (candidate, edit_distance(unknown, candidate)))
        .filter(|&(candidate, distance)| {
            distance > 0 && distance <= (unknown.len().max(candidate.len()) / 3).max(1)
        })
        .min_by_key(|&(_, distance)| distance)
        .map(|(candidate, _)| candidate)
}

/// Levenshtein edit distance, for [`near_match`].
fn edit_distance(a: &str, b: &str) -> usize {
    let a: Vec<char> = a.chars().collect();
    let b: Vec<char> = b.chars().collect();

    let mut prev: Vec<usize> = (0..=b.len()).collect();
    let mut curr = vec![0usize; b.len() + 1];

    for i in 1..=a.len() {
        curr[0] = i;
        for j in 1..=b.len() {
            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
        }
        std::mem::swap(&mut prev, &mut curr);
    }

    prev[b.len()]
}

/// RFC 049 Goal 4: give a bare relative path (no directory component,
/// e.g. `"apimock.toml"`) an explicit `./` prefix, so it resolves the
/// same way `"./apimock.toml"` already does. Absolute paths and paths
/// that already have a directory component (including a leading `./`
/// or `../`) are returned unchanged.
fn normalize_bare_relative_path(path: String) -> String {
    // `args_option_value` returns `Some("")` for a value-taking flag
    // given with nothing after it (the same encoding it uses for a
    // boolean flag's mere presence) - already a meaningless invocation
    // either way, but prepending "./" to it would turn "path not given"
    // into "path is the current directory", a more confusing failure
    // than the original "no such file" for an empty path. Leave it
    // alone so it fails the same way it always did.
    if path.is_empty() {
        return path;
    }

    let p = Path::new(&path);
    let has_directory_component = p
        .parent()
        .is_some_and(|parent| !parent.as_os_str().is_empty());
    if has_directory_component || p.is_absolute() {
        path
    } else {
        format!("./{}", path)
    }
}

/// Top-level usage text, or a subcommand's, matching
/// `docs/src/reference/cli-reference.md`.
fn help_text(subcommand: Option<&str>) -> &'static str {
    match subcommand {
        Some("match-test") => {
            "apimock match-test --rule-set <path> [--rule <n>] [--path <url_path>] \\\n  [--method <METHOD>] [--header \"Name: value\"]... \\\n  [--body <json> | --body-file <path>] [--quiet]\n\nBuilds a synthetic request from the flags below and checks it against\na rule set directly - no server, no network request.\n\n  --rule-set, -r <path>       Required. The rule-set file to check against\n  --rule <n>                  Check only this rule, 1-based\n  --path, -p <url_path>       The synthetic request's URL path\n  --method, -m <METHOD>       The synthetic request's HTTP method\n  --header, -H \"Name: value\"  Add a header; repeatable\n  --body, -b <json>           The synthetic request's JSON body, inline\n  --body-file <path>          The synthetic request's JSON body, from a file\n  --quiet, -q                 Suppress the per-condition breakdown\n\nExit codes: 0 matched, 1 no rule matched, 2 an argument or input error."
        }
        Some("validate") => {
            "apimock validate --config <path> [--strict] [--quiet] [--json]\n\nLoads the whole workspace - root config and every rule set it\nreferences - and reports diagnostics, without binding a port.\n\n  --config, -c <path>  Required. The root config to validate\n  --strict             Treat warnings as failures too\n  --quiet              Suppress non-error output\n  --json               Emit diagnostics as a JSON array\n\nExit codes: 0 clean, 1 at least one error, 2 the config couldn't be loaded."
        }
        _ => {
            "apimock [-p <port>] [-d <dir>] [-c <config>] [--init [--yes] [--middleware]]\n\nRun with no flags to serve the current directory: zero-config mode\nserves ./ by URL path on port 3001, or ./apimock.toml if it exists.\n\n  -c, --config <path>  Load a config file (a bare relative path resolves\n                       the same as one prefixed with ./)\n  -p, --port <port>    Listen on a custom port\n  -d, --dir <dir>      Serve a custom fallback directory instead of ./\n  --init               Scaffold a starting config in the current directory\n  --yes                With --init, skip prompts and accept defaults\n  --middleware         With --init, also scaffold a middleware file\n  -h, --help           Print this help and exit\n  --version            Print the version and exit\n\nSubcommands:\n  match-test  Dry-run a rule match against a rule set, no server\n  validate    Validate a config, no server\n\nRun 'apimock <subcommand> --help' for subcommand-specific help."
        }
    }
}