surpher/PactSwift

Nested arrayContains reifies to null in generated body (bundled pact_ffi 0.4.7)

Open

#133 opened on Jul 15, 2026

View on GitHub
 (0 comments) (0 reactions) (0 assignees)Swift (19 forks)auto 404
Trivialgood first issue

Repository metrics

Stars
 (55 stars)
PR merge metrics
 (PR metrics pending)

Description

Summary

An arrayContains matcher that is nested inside another arrayContains reifies to null in the generated mock-server body, instead of to a concrete array. Strict consumer-side decoding of that body then fails. Top-level arrayContains works correctly; only nesting is affected.

The root cause is in the bundled pact_ffi (0.4.7), not in the Swift layer.

Environment

  • Bundled FFI: pact_ffi 0.4.7, pact_models 1.1.9 (read from PactSwiftMockServer.xcframework)
  • Dependency chain: PactSwiftPactSwiftServer 0.4.7PactMockServer 0.1.2 (the libpact_ffi xcframework)
  • Discovered while using a fork that adds an arrayContains matcher (pepejeria/PactSwift@feat/array-contains-matcher), but the underlying defect is in the bundled FFI and affects the whole surpher stack.

Root cause

In pact_ffi 0.4.7, rust/pact_ffi/src/mock_server/bodies.rsprocess_object, the example body for an arrayContains matcher is generated by re-processing its variants array with skip_matchers = true:

let (value, skip_matchers) = if let Ok(rule) = &matching_rule {
  match rule {
    MatchingRule::ArrayContains(_) => (obj.get("variants"), true), // reprocess variants, skip_matchers = true
    _ => (obj.get("value"), false)
  }
} ...;

Under skip_matchers = true, any object carrying pact:matcher:type is reified only from its "value" key:

} else { // skip_matchers == true
  match obj.get("value") {
    Some(val) => ...,
    None => Value::Null   // <-- an arrayContains object has no "value" -> null
  }
}

An arrayContains matcher serialises as { "pact:matcher:type": "arrayContains", "variants": [...] } with no "value" key. So:

  • Top-level arrayContains works — reached via the non-skip path, which reads variants.
  • Nested arrayContains collapses to null — reached only via the skip-matchers path, which looks for "value" and finds none.

Already fixed upstream

In pact_ffi ≥ 0.4.9, process_matcher was refactored so the arrayContains branch recursively builds its example from variants and returns Value::Array(json_values), independent of any "value" key. On that version nested arrayContains works with no client change. So bumping the bundled libpact_ffi (via PactMockServer / PactSwiftServer) to ≥ 0.4.9 is the clean fix.

Reproduction

An end-to-end consumer test with a nested arrayContains, run against the real mock server:

try response.jsonBody(
    .arrayContains([
        [
            "id": .like("outer-1"),
            "children": .arrayContains([
                ["childId": .like("inner-1")],
            ]),
        ],
    ])
)

Fetch the generated body inside builder.verify { ... } and inspect it:

  • Actual (0.4.7): the top-level array is present, but children is null.
  • Expected: children is a non-empty array ([{ "childId": "inner-1" }]).

Proposed fixes

Bump the bundled libpact_ffi to ≥ 0.4.9 in PactMockServer / PactSwiftServer, where arrayContains example generation is recursive and no longer depends on a "value" key.

Contributor guide