This document describes how PactEx is built and how a consumer test flows from your ExUnit code down to the Pact mock server.
Overview
PactEx is a Rustler NIF over pact_ffi 0.5.12, the Rust library behind most Pact implementations. The NIF crate (native/pact_ex_nif) depends on pact_ffi as a regular Rust crate and calls its pactffi_* functions directly. There is no C code and no downloaded .a file. Users get a precompiled NIF through rustler_precompiled.
The current release covers HTTP consumer tests: building interactions, running them against a mock server, and writing the pact file. Messages, plugins (gRPC) and provider verification are planned (see Roadmap).
High-Level Architecture
┌──────────────────────────────────────────────────────────────────┐
│ Your ExUnit test / application │
└───────────────────────────────┬──────────────────────────────────┘
│ PactEx.* API
┌───────────────────────────────▼──────────────────────────────────┐
│ PactEx (Elixir, pure data) │
│ ┌────────────┐ ┌──────────────┐ ┌───────────────────────────┐ │
│ │ PactEx │ │ PactEx.HTTP │ │ PactEx.Matchers │ │
│ │ (new/3) │ │ (builders) │ │ (like, regex, each_like…) │ │
│ └────────────┘ └──────────────┘ └───────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────┐│
│ │ PactEx.Consumer run/4, run!/4, start_mock_server/3 ││
│ │ ├─ Consumer.Apply plan + encode (no FFI builder calls) ││
│ │ └─ Consumer.BodyCheck XML / form description checks ││
│ └──────────────────────────────────────────────────────────────┘│
│ ┌────────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │
│ │ PactEx.Mock- │ │ PactEx.Log │ │ PactEx.Error / │ │
│ │ Server │ │ │ │ PactEx.MismatchError │ │
│ └────────────────┘ └─────────────┘ └──────────────────────────┘ │
└───────────────────────────────┬──────────────────────────────────┘
│ PactEx.Native (NIF stubs)
┌───────────────────────────────▼──────────────────────────────────┐
│ pact_ex_nif (Rust, Rustler 0.38) │
│ handles.rs http.rs mock_server.rs content_type.rs │
│ logger.rs error.rs stack.rs args.rs │
│ - Resources for pact / interaction handles │
│ - Error mapping (thread-local last error → error map) │
│ - Dirty schedulers + 8 MiB stack for blocking calls │
└───────────────────────────────┬──────────────────────────────────┘
│ pactffi_* (Rust calls, C ABI)
┌───────────────────────────────▼──────────────────────────────────┐
│ pact_ffi 0.5.12 / pact_models 1.3.17 │
│ - Pact model, matching rules, generators │
│ - Mock server (Tokio runtime, own threads) │
│ - Pact file writing (with file locks) │
└──────────────────────────────────────────────────────────────────┘Core Components
1. Public API
PactEx.new/3(lib/pact_ex.ex,lib/pact_ex/pact.ex): builds a%PactEx.Pact{consumer, provider, spec, pact_dir}. Defaults: spec:v4,pact_dir"pacts". It is a plain struct; nothing native happens yet.PactEx.HTTP(lib/pact_ex/http.ex,lib/pact_ex/http/*.ex): pure builders that return immutable structs (Interaction,Request,Response):interaction/1,given/2,3,with_request/4,will_respond_with/3. They validate their input (guards andKeyword.validate!/2) and normaliseheaders:andquery:given as maps, keyword lists or tuple lists. Atom header names are written the way Req writes them (:x_request_idbecomesx-request-id).PactEx.Matchers(lib/pact_ex/matchers.ex): functions that return pact_ffi "integration JSON" maps (%{"pact:matcher:type" => ...}). Each example satisfies its own rule (for example,each_like/2repeats the examplemintimes).
2. Consumer Layer
PactEx.Consumer (lib/pact_ex/consumer.ex)
The entry point for tests.
start_mock_server/3validates every interaction, creates a fresh FFI pact, applies the interactions and starts the mock server. It returns a%PactEx.MockServer{}.run/4wraps it:- start the server;
- call your function;
- check that every expected request arrived and no other did;
- write the pact in merge mode.
The server is stopped in an
afterblock, so it stops when the function returns, raises, throws or exits. Nothing is written on a mismatch or an exception.run!/4raises the error instead of returning it.
There is no GenServer and no shared state. Every run/4 call gets its own pact handle and mock server, so async: true tests are safe.
PactEx.Consumer.Apply (lib/pact_ex/consumer/apply.ex, internal)
Turns an Interaction into native calls in two phases:
- Plan:
plan/1decides everything before any FFI builder call:- the encoding of query, header and path values (strings, matchers, scalars, lists);
- one canonical content type per part (explicit
:content_type, then theContent-Typeheader, then the body default; each trimmed and checked with pact_models' own parser); - the canonical
Content-Typeheader; - the rejection of values pact_ffi would drop or crash on.
- Apply:
interaction/2creates the interaction handle and calls the builder NIFs with exactly what the plan decided.
Every JSON PactEx writes is decoded again with a decoder that fails on a repeated key. So the bytes checked are exactly the bytes sent.
PactEx.Consumer.BodyCheck (lib/pact_ex/consumer/body_check.ex, internal)
pact_ffi reads a JSON body under an XML or application/x-www-form-urlencoded content type as a description of the body. Some descriptions make pact_ffi panic inside an extern "C" function, which aborts the BEAM. BodyCheck validates those descriptions, including string bodies that pact_ffi's sniffing takes for JSON, and returns {:error, %PactEx.Error{code: -101}} naming the interaction and the path of the problem.
3. Mock Server (lib/pact_ex/mock_server.ex)
%PactEx.MockServer{port, url, pact_dir, pact_ref} wraps a running pact_ffi mock server:
matched?/1mismatches/1(decoded JSON)write_pact/2(merge by default)stop/1
It keeps a reference to the pact resource, so the pact handle lives as long as the server struct.
4. Logging (lib/pact_ex/log.ex)
PactEx.Log.init/1 configures pact_ffi's global logger (stdout, stderr or a file, at a given level). pact_ffi installs one logger per OS process, so init/1 succeeds once. Later calls return {:error, :already_initialized}. No log callbacks are registered, because they would fire on Tokio threads.
5. Errors (lib/pact_ex/error.ex, lib/pact_ex/mismatch_error.ex)
PactEx.Errorhas fieldsfunction,codeandmessage. Codes:-100: an argument contained a NUL byte. It is rejected before calling pact_ffi.-101: PactEx rejected an interaction before calling pact_ffi, because it is invalid or pact_ffi would drop or crash on it.- Other codes come from pact_ffi. The message is pact_ffi's own error text when it stored one, or a description of the code.
PactEx.MismatchErrorcarries the decoded mismatch list from the mock server: missing requests, unexpected requests, body or header mismatches.
6. NIF Layer
Elixir side (lib/pact_ex/native.ex)
PactEx.Native uses RustlerPrecompiled:
- It downloads the NIF for the current target, or builds it from source in dev/test, or when
PACT_EX_BUILD=1. - It declares a stub for each NIF.
- It is
@moduledoc false. Users never call it directly.
Rust side (native/pact_ex_nif/src/)
| File | Responsibility |
|---|---|
handles.rs | PactResource / InteractionResource (Rustler resources). Drop frees the pact handle. An interaction keeps its pact alive. NIFs: new_pact, with_specification, new_interaction, given, given_with_params. |
http.rs | Request and response builders: with_request, with_query_parameter, with_header, with_body, with_binary_body, response_status. |
mock_server.rs | create_mock_server, mock_server_matched, mock_server_mismatches, write_pact_file, cleanup_mock_server. All on dirty IO schedulers. A crate-level mutex serialises reading mismatches and cleanup. |
content_type.rs | Pure helpers that reuse pact_models and serde_json: content_type_kind (parse and classify a content type), detect_json (pact_models' JSON sniffing) and json_text_shape (serde_json parse). The last two run on dirty CPU schedulers. |
logger.rs | init_logger: logger init, attach sink and apply, in one call. |
error.rs | FfiError, Outcome, clear_error / last_error (thread-local pact_ffi error message), to_cstring (NUL check). |
stack.rs | on_large_stack: runs a closure on a thread with an 8 MiB stack. |
args.rs | decode_arg, so an unknown atom raises ArgumentError. |
7. pact_ffi (external)
Provides the Pact model, matching rules and generators, the mock server (on its own Tokio runtime), and pact file writing. pact_models serialises concurrent merges of the same pact file with a process mutex and a file lock.
Data Flow
Consumer workflow
pact = PactEx.new("web", "users-api")
interaction =
PactEx.HTTP.interaction("get user")
|> PactEx.HTTP.given("user 1 exists")
|> PactEx.HTTP.with_request(:get, "/users/1")
|> PactEx.HTTP.will_respond_with(200, body: %{"id" => PactEx.Matchers.integer(1)})
:ok = PactEx.Consumer.run(pact, [interaction], fn mock ->
Req.get!(mock.url <> "/users/1")
end)- Build (pure Elixir).
PactEx.new/3and thePactEx.HTTPbuilders produce structs. Matchers are plain maps. - Validate (no FFI builder calls).
start_mock_server/3runsApply.validate/1on every interaction. Invalid input returns{:error, %PactEx.Error{code: -101}}, and nothing native has been created yet. - Create the pact.
Native.new_pact/2returns a resource, andNative.with_specification/2sets the spec. - Apply. For each interaction:
Native.new_interaction/2, thengiven/given_with_params;- the request:
with_request,with_query_parameter,with_header,with_bodyorwith_binary_body; - the response:
response_status,with_header,with_body.
- Start.
Native.create_mock_server/5binds127.0.0.1on an OS-chosen port by default. - Exercise. Your function talks to
mock.url. - Verify and write.
MockServer.matched?/1:- matched:
MockServer.write_pact/2writes<consumer>-<provider>.jsonin merge mode; - not matched:
run/4reads the mismatches into aPactEx.MismatchError.
- matched:
- Stop.
MockServer.stop/1runs last, because the mock server owns the mismatches JSON and frees it on cleanup.
Provider workflow
Not implemented yet. Provider verification is planned (see Roadmap).
Threading and Safety
- Dirty schedulers: NIFs that block or take a pact_ffi mutex across I/O run on dirty IO schedulers: mock server creation, matching, mismatches, writing, cleanup and logger init. The JSON sniffing and parsing helpers run on dirty CPU schedulers, because bodies can be large.
- Large stack: blocking calls run inside
on_large_stack/1(an 8 MiB thread). Futures that pact_ffi drives withblock_onrun their outer frames on the calling thread, and dirty scheduler stacks are small. - Thread-local errors: pact_ffi stores its last error message in a thread-local. Each fallible NIF calls
clear_error, the FFI function andlast_erroron the same thread, inside the same NIF call (insideon_large_stackwhen one is used). - Use after free: the mismatches string is owned by the mock server manager. A crate mutex is held across the mismatches read and copy, and across cleanup.
- VM aborts: a panic inside pact_ffi's
extern "C"functions cannot unwind into the BEAM, so it aborts the whole VM. PactEx therefore validates every input pact_ffi is known to crash on (XML and form descriptions, emptyContent-Typevalues, repeated JSON keys) before any native call. The abort matrix test (test/abort_matrix_test.exs) runs each risky input in its own OS process, so an abort is a test failure, not a dead suite.
Build System
Native dependencies
- The Rust crate
pact_ex_nifpinspact_ffi = "=0.5.12",pact_models = "=1.3.17"andrustler = "0.38", plusserde_json.Cargo.lockis committed and shipped. - Minimum Rust is 1.91 (
rust-version). Noprotocis needed.
Compilation
- Users:
rustler_precompileddownloads the NIF for their target from the GitHub release and checks it againstchecksum-Elixir.PactEx.Native.exs. - Dev/test:
config/config.exsforces a source build. Elsewhere,PACT_EX_BUILD=1forces one too. It is read at compile time, so changing it needsmix deps.compile pact_ex --force.
Platforms
- The targets are macOS, Linux glibc and Linux musl, each on
x86_64andaarch64, for NIF versions 2.16 and 2.17. There is no Windows build. - Measured glibc floors: x86_64-gnu needs 2.34, and aarch64-gnu needs 2.18.
CI and release (.github/workflows/)
ci.yml:- runs the tests (including the abort matrix),
cargo test, fmt, clippy and--warnings-as-errors; - matrix: Linux and macOS, with OTP 26/27/28.
- runs the tests (including the abort matrix),
release.yml:- builds the 12 NIFs with
philss/rustler-precompiled-actionand checks that each tarball holds onlylibpact_ex_nif; - checks the glibc floor;
- on pull requests that touch native code, and on manual runs, it is a dry run: builds and checks only;
- publishes a GitHub release only for
v*tag pushes.
- builds the 12 NIFs with
- Actions are pinned by commit SHA. See
RELEASING.md.
Error Handling
Propagation
- pact_ffi returns a status code, a boolean or a null pointer, and sets a thread-local error message.
- NIF turns that into
:ok,{:ok, value}or{:error, %{function, code, message}}. Bad argument types and NUL bytes in handle-creating calls raiseArgumentError. - Elixir wraps error maps in
%PactEx.Error{}.run/4returns{:error, %PactEx.Error{}}or{:error, %PactEx.MismatchError{}}, andrun!/4raises them.
Resources
- Pact handles are freed by the resource destructor when the last reference is garbage collected.
- Mock servers are stopped by
run/4in anafterblock, or explicitly withMockServer.stop/1. A brutal kill (an ExUnit timeout or a linked crash) skipsafter, and the server lives until the OS process exits.
Configuration
- Pact:
consumerandprovidernames;spec(:v2,:v3,:v4; default:v4);pact_dir(default"pacts").
- Mock server:
host(default"127.0.0.1") andport(default0, OS-assigned) instart_mock_server/3andrun/4. Logging:
PactEx.Log.init(level: ..., sink: :stdout | :stderr | {:file, path}).- Build:
PACT_EX_BUILD=1builds from source.
Testing
| Location | Scope |
|---|---|
native/pact_ex_nif/src/*.rs (cargo test) | Rust unit tests: error mapping, the large stack, the lock, content type helpers, logger messages. |
test/pact_ex/native/ | Each NIF, its success path and every error path, against the real pact_ffi. |
test/pact_ex/ | Builders, matchers, Apply, BodyCheck, Consumer, MockServer, Log, end to end with a real mock server, Req and checks of the pact file. |
test/acceptance/ | The pact_erlang HTTP parity suite, an end-to-end test per matcher, concurrent writes of the same pact file. |
test/abort_matrix_test.exs | Risky inputs, each in its own OS process, with a pinned outcome. |
Roadmap
Plans live in docs/superpowers/plans/:
- Plan 2: async and sync messages, Pact plugins (
pactffi_using_plugin,pactffi_interaction_contents) and the gRPC mock transport through the protobuf plugin. - Plan 3: provider verification from files, directories, URLs or a Pact Broker. It covers state change callbacks and message providers through an internal Plug endpoint, publishing results, and plugin transports.
Open follow-ups are tracked in TODO.md.