pact-foundation / pact-js

JS version of Pact. Pact is a contract testing framework for HTTP APIs and non-HTTP asynchronous messaging systems.
https://pact.io
Other
1.58k stars 342 forks source link

V4InteractionWithCompleteRequest is unimplemented #1224

Open shishkin opened 1 week ago

shishkin commented 1 week ago

I'm trying to write a pact for multipart/form-data with file upload. When I use V4 DSL withRequest I get body content type mismatch because my test uses random boundary for multipart body. Then I tried withCompleteRequest so I can just use regex matcher for the body. PactJS gives me Error: V4InteractionWithCompleteRequest is unimplemented.

Software versions

Issue Checklist

Please confirm the following:

Expected behaviour

There should be a way to relax request matching while still providing a specific example for provider verification.

Actual behaviour

Error: V4InteractionWithCompleteRequest is unimplemented

Steps to reproduce

await new PactV4({
        provider: "P",
        consumer: "C",
        logLevel: "debug",
    })
    .addInteraction()
    .uponReceiving("...")
    .withCompleteRequest({
        method: "POST",
        path: "/upload-file",
        headers: {
            accept: M.regex(/application\/json/, "application/json"),
            "content-type": M.regex(
                /^multipart\/form-data.+$/,
                `multipart/form-data; boundary=${boundary}`,
            ),
        },
        body: M.regex(/.*/, bodyBuffer.toString("utf-8")),
    })
    .withCompleteResponse({
        status: 200,
        contentType: "application/json; charset=utf-8",
        body: {
            success: true,
        },
    })
    .executeTest(async (server) => {
        const form = new FormData();
        form.append("uploadPath", "/blog");
        form.append(
            "files",
            await openAsBlob(testImagePath),
            "image.png",
        );
        await fetch(new URL("/upload-file", server.url), {
            method: "POST",
            headers: {
                accept: "application/json",
            },
            body: form,
        });
    });

Relevant log files

2024-06-21T05:18:39.925518Z DEBUG tokio-runtime-worker pact_mock_server::hyper_server:
      ----------------------------------------------------------------------------------------
       method: POST
       path: /upload-file
       query: None
       headers: Some({"connection": ["keep-alive"], "host": ["127.0.0.1:58624"], "accept-language": ["*"], "accept-encoding": ["gzip", "deflate"], "content-length": ["363"], "accept": ["application/json"], "user-agent": ["node"], "sec-fetch-mode": ["cors"], "content-type": ["multipart/form-data; boundary=----formdata-undici-074263581352"]})
       body: Present(363 bytes, multipart/form-data;boundary=----formdata-undici-074263581352)
      ----------------------------------------------------------------------------------------

2024-06-21T05:18:39.925555Z  INFO tokio-runtime-worker pact_matching: comparing to expected HTTP Request ( method: POST, path: /upload-file, query: None, headers: Some({"accept": ["application/json"], "content-type": ["multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"]}), body: Present(362 bytes) )
2024-06-21T05:18:39.925564Z DEBUG tokio-runtime-worker pact_matching:      body: '2D2D2D2D5765624B6974466F726D426F756E64617279374D41345957786B5472... (362 bytes)'
2024-06-21T05:18:39.925565Z DEBUG tokio-runtime-worker pact_matching:      matching_rules: MatchingRules { rules: {BODY: MatchingRuleCategory { name: BODY, rules: {DocPath { path_tokens: [Root], expr: "$" }: RuleList { rules: [ContentType("multipart/form-data")], rule_logic: And, cascaded: false }} }, PATH: MatchingRuleCategory { name: PATH, rules: {} }, HEADER: MatchingRuleCategory { name: HEADER, rules: {DocPath { path_tokens: [Root, Field("content-type")], expr: "$['content-type']" }: RuleList { rules: [Regex("^multipart\\/form-data.+$")], rule_logic: And, cascaded: false }} }} }
2024-06-21T05:18:39.925573Z DEBUG tokio-runtime-worker pact_matching:      generators: Generators { categories: {} }
2024-06-21T05:18:39.925594Z DEBUG tokio-runtime-worker pact_matching::matchers: String -> String: comparing '/upload-file' to '/upload-file' ==> true cascaded=false matcher=Equality
2024-06-21T05:18:39.925602Z DEBUG tokio-runtime-worker pact_matching: expected content type = 'multipart/form-data;boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW', actual content type = 'multipart/form-data;boundary=----formdata-undici-074263581352'
2024-06-21T05:18:39.925619Z DEBUG tokio-runtime-worker pact_matching: content type header matcher = 'RuleList { rules: [], rule_logic: And, cascaded: false }'
2024-06-21T05:18:39.925670Z DEBUG tokio-runtime-worker pact_matching::matchers: String -> String: comparing 'multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' to 'multipart/form-data; boundary=----formdata-undici-074263581352' ==> true cascaded=false matcher=Regex("^multipart\\/form-data.+$")
2024-06-21T05:18:39.925677Z DEBUG tokio-runtime-worker pact_matching: --> Mismatches: [BodyTypeMismatch { expected: "multipart/form-data;boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW", actual: "multipart/form-data;boundary=----formdata-undici-074263581352", mismatch: "Expected a body of 'multipart/form-data;boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' but the actual content type was 'multipart/form-data;boundary=----formdata-undici-074263581352'", expected_body: Some(b"----WebKitFormBoundary7MA4YWxkTrZu0gW\nContent-Disposition: form-data; name=\"files\"; filename=\"white.png\"\nContent-Type: application/octet-stream\n\n\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01\x08\x06\0\0\0\x1f\x15\xc4\x89\0\0\0\x01sRGB\0\xae\xce\x1c\xe9\0\0\0\rIDAT\x18Wc\xf8\xff\xef\xdf\x7f\0\t\xf6\x03\xfbW\xfe{\x1b\0\0\0\0IEND\xaeB`\x82\n----WebKitFormBoundary7MA4YWxkTrZu0gW\nContent-Disposition: form-data; name=\"uploadPath\"\n\n/blog\n----WebKitFormBoundary7MA4YWxkTrZu0gW\n"), actual_body: Some(b"------formdata-undici-074263581352\r\nContent-Disposition: form-data; name=\"uploadPath\"\r\n\r\n/blog\r\n------formdata-undici-074263581352\r\nContent-Disposition: form-data; name=\"files\"; filename=\"image.png\"\r\nContent-Type: application/octet-stream\r\n\r\n\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01\x08\x06\0\0\0\x1f\x15\xc4\x89\0\0\0\x01sRGB\0\xae\xce\x1c\xe9\0\0\0\rIDAT\x18Wc\xf8\xff\xef\xdf\x7f\0\t\xf6\x03\xfbW\xfe{\x1b\0\0\0\0IEND\xaeB`\x82\r\n------formdata-undici-074263581352--") }]
2024-06-21T05:18:39.925738Z DEBUG tokio-runtime-worker pact_mock_server::hyper_server: Request did not match: Request did not match - HTTP Request ( method: POST, path: /upload-file, query: None, headers: Some({"accept": ["application/json"], "content-type": ["multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"]}), body: Present(362 bytes) )    0) Expected a body of 'multipart/form-data;boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' but the actual content type was 'multipart/form-data;boundary=----formdata-undici-074263581352'
mefellows commented 1 week ago

Even if we were to implement the V4InteractionWithCompleteRequest method, it would not solve your problem - regex only applies to fields on JSON bodies.

You could use the PactV3 interface, and just set the spec version to 4 (I think that's possible), for equivalent capability.

It should be straightforward enough to implement the requested feature though if you're keen to contribute that.

As to your actual need:

mismatch because my test uses random boundary for multipart body

What we need, possibly, is to support multipart requests with random boundaries.

I'm confused by this part of your code though:

    .withCompleteRequest({
        method: "POST",
        path: "/upload-file",
        headers: {
            accept: M.regex(/application\/json/, "application/json"),
            "content-type": M.regex(
                /^multipart\/form-data.+$/,
                `multipart/form-data; boundary=${boundary}`, // <------- do you know the boundary here?
            ),
        },
        body: M.regex(/.*/, bodyBuffer.toString("utf-8")),
    })
shishkin commented 1 week ago

do you know the boundary here?

That is the example boundary I want to send to the provider. I extracted it into a variable because it's used multiple times for constructing example bodyBuffer. But I don't know the boundary that node fetch will use inside the executeTest.

To be honest, I'm very confused by pact architecture and support roadmap. All language libraries seems to deviate a lot from each other and the zoo of versions (V2/V3/V4) and indirection layers (JS/native/ffi/...) is incomprehensible. Where do I even start?

mefellows commented 1 week ago

Some background on the ecosystem here: https://docs.pact.io/diagrams/ecosystem

The spec versions (V2/V3/V4 as you say) relate to the contract file serialisation format and supported features the contract file can model.

The FFI is the shared library where the key business logic resides to avoid repeating the effort across 10+ languages (and multiple spec versions).

In this case, that particular method you wanted can call the existing FFI methods that are already exposed to Pact JS (example for the V3 interface: https://github.com/pact-foundation/pact-js/blob/master/src/v3/pact.ts#L111-L121). Ultimately, they would just call the same functions that the type-state builder DSL already calls.


@rholshausen is there a way to have the mock server match multipart bodies where the boundary name is unknown in advance? I've looked at the fetch API and other common clients and most don't seem to have a way to set them (i.e. they're generated).

rholshausen commented 1 week ago

The mock server does not need to know the boundary in advance. As long as the boundary value in the content type header matches that used in the body, it will parse it correctly (i.e. it has to be a correctly formed multipart body).

The main issue is that the actual and expected content type headers will have values that don't match. That is why the regex for the header is important.

rholshausen commented 1 week ago

The issue is seen in the logs above. The content type header is been treated as different, so the body is not being compared.

mefellows commented 1 week ago

As long as the boundary value in the content type header matches that used in the body

Sorry, that's the point I think - a lot of the HTTP clients don't let you specify the boundary names (they are generated) so you can't specify the boundary attribute in the content-type header - this is what I meant by telling the mock server in advance.

rholshausen commented 1 week ago

Normally both the content type header and body is generated. You need to be able to retrieve the header, or get the header to be set.

shishkin commented 1 week ago

The main issue is that the actual and expected content type headers will have values that don't match. That is why the regex for the header is important.

I believe that it the problem, because Pact JS DSL requires body content type to be a fixed string and not a RegEx. So I'm not able to specify the content type as a RegEx in both the headers matcher and the body example.

mefellows commented 1 week ago

That's slightly different, I think. The implementation for headers calls a separate FFI method: https://github.com/pact-foundation/pact-js/blob/4d78c65b04ee8adc5a48aa4adae1230ce8abf61d/src/v4/http/index.ts#L163-L168 which calls the underlying FFI method: pactffi_with_header_v2. This is what sets the matching on the content-type header.

The method you linked to identifies the specific content type on the body, if known (see https://github.com/pact-foundation/pact-reference/blob/efc54d263e7ea53c9511cf389870e790c0158bc3/rust/pact_ffi/src/mock_server/handles.rs#L1861).

If you shared a wider log file @shishkin you would likely see the content-type header matching rules setup, along with your regex.

@rholshausen correct me if I'm wrong here?