|
| 1 | +--- |
| 2 | +name: define-matching-rules-for-form-urlencoded-body |
| 3 | +started: 2024-09-06 |
| 4 | +pr: pact-foundation/roadmap#0000 |
| 5 | +tracking_issue: pact-foundation/roadmap#0000 |
| 6 | +--- |
| 7 | +## Summary |
| 8 | + |
| 9 | +This RFC proposes defining matching rules for bodies of type `application/x-www-form-urlencoded`. |
| 10 | + |
| 11 | +## Motivation |
| 12 | + |
| 13 | +Currently we have the method [`match_form_urlencoded`](https://github.com/pact-foundation/pact-reference/blob/master/rust/pact_matching/src/form_urlencoded.rs#L12) to match the `application/x-www-form-urlencoded` request's body using matching rules. |
| 14 | +But there is no way to define matching rules for the body to fully take advantage of this feature. |
| 15 | + |
| 16 | +The only a way to define body for a `application/x-www-form-urlencoded` request is to define it as-is. |
| 17 | + |
| 18 | +For example, we can define the request's body as `fullname=My+Full+Name&email=test%40example.com&password=abc%40123`. |
| 19 | +Then the consumer **must** send the request with body exactly like this: `fullname=My+Full+Name&email=test%40example.com&password=abc%40123`, or |
| 20 | +otherwise consumer's Pact test will failed, and the pact file will not be written. |
| 21 | + |
| 22 | +That's because we can't define matching rules with this raw syntax. With the ability to define matching rules with the alternative syntax, |
| 23 | +consumer will be able to send different values. |
| 24 | + |
| 25 | +This example is focusing on request's body, but it also apply to response's body. |
| 26 | + |
| 27 | +## Guide-level explanation |
| 28 | + |
| 29 | +### With matching rules |
| 30 | + |
| 31 | +When the RFC is implemented, the example usage would be: |
| 32 | + |
| 33 | +```rust |
| 34 | +let json = json!({ |
| 35 | + "number": { |
| 36 | + "pact:matcher:type": "number", |
| 37 | + "value": 23.45 |
| 38 | + }, |
| 39 | + "string": { |
| 40 | + "pact:matcher:type": "type", |
| 41 | + "value": "example text" |
| 42 | + }, |
| 43 | + "array": { |
| 44 | + "pact:matcher:type": "eachValue(matching(regex, 'value1|value2|value3|value4', 'value2'))", |
| 45 | + "value": ["value1", "value4"] |
| 46 | + } |
| 47 | +}); |
| 48 | +pactffi_with_body(interaction, InteractionPart::Request, "application/x-www-form-urlencoded", json); |
| 49 | +``` |
| 50 | + |
| 51 | +After the matching rules are extracted, the example body will be returned: |
| 52 | + |
| 53 | +```query |
| 54 | +number=23.45&string=example+text&array=value1&array=value4 |
| 55 | +``` |
| 56 | + |
| 57 | +This example body (along with matchers) will be written into pact file. |
| 58 | + |
| 59 | +### Without matching rules |
| 60 | + |
| 61 | +We can also define body without matching rules: |
| 62 | + |
| 63 | +```rust |
| 64 | +let json = json!({ |
| 65 | + "number": 123, |
| 66 | + "string": "example value", |
| 67 | + "array": [null, -123.45, "inner text"], |
| 68 | +}); |
| 69 | +pactffi_with_body(interaction, InteractionPart::Request, "application/x-www-form-urlencoded", json); |
| 70 | +``` |
| 71 | + |
| 72 | +The example body will be extracted and look like this: |
| 73 | + |
| 74 | +``` |
| 75 | +number=123&string=example+value&array=-123.45&array=inner+text |
| 76 | +``` |
| 77 | + |
| 78 | +It will be exactly the same as we define the body using raw syntax: |
| 79 | + |
| 80 | +```rust |
| 81 | +let raw = "number=123&string=example+value&array=-123.45&array=inner+text"; |
| 82 | +pactffi_with_body(interaction, InteractionPart::Request, "application/x-www-form-urlencoded", raw); |
| 83 | +``` |
| 84 | + |
| 85 | +### Generators |
| 86 | + |
| 87 | +Generators can be defined like this: |
| 88 | + |
| 89 | +```rust |
| 90 | +let json = json!({ |
| 91 | + "id": { |
| 92 | + "pact:matcher:type": "regex", |
| 93 | + "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$", |
| 94 | + "pact:generator:type": "Uuid" |
| 95 | + }, |
| 96 | + "age": { |
| 97 | + "pact:matcher:type": "integer", |
| 98 | + "value": 0, |
| 99 | + "max": 130, |
| 100 | + "min": 0, |
| 101 | + "pact:generator:type": "RandomInt" |
| 102 | + }, |
| 103 | + "name": [ |
| 104 | + { |
| 105 | + "pact:matcher:type": "regex", |
| 106 | + "regex": "Mr\\.|Mrs\\.|Miss|Ms\\.", |
| 107 | + "value": "", |
| 108 | + "pact:generator:type": "Regex" |
| 109 | + }, |
| 110 | + { |
| 111 | + "pact:matcher:type": "type", |
| 112 | + "value": "", |
| 113 | + "size": 10, |
| 114 | + "pact:generator:type": "RandomString" |
| 115 | + } |
| 116 | + ] |
| 117 | +}); |
| 118 | +pactffi_with_body(interaction, InteractionPart::Response, "application/x-www-form-urlencoded", json); |
| 119 | +``` |
| 120 | + |
| 121 | +Consumer will receive response's body like this from mock server: |
| 122 | + |
| 123 | +``` |
| 124 | +id=46441a68-1d3d-40f2-bbec-6e9bd26a4047&age=65&name=Mr.&name=Ea8XIXh7NQ |
| 125 | +``` |
| 126 | + |
| 127 | +### Unsupported syntax |
| 128 | + |
| 129 | +These values are not supported: |
| 130 | + |
| 131 | +- Null: There is no way to represent null in query string. User may want to define empty string instead. |
| 132 | +- Boolean (true/false): There is no standard way to represent boolean in query string. It can be 1/0, true/false or t/f. User need to define them explicitly. |
| 133 | +- Object: There is no standard way to represent object in query string. |
| 134 | +- Array of Arrays: There is no standard way to represent array of arrays in query string. |
| 135 | +- Array of Objects: There is no standard way to represent array of objects in query string. |
| 136 | + |
| 137 | + |
| 138 | +#### With matching rules: |
| 139 | + |
| 140 | +```rust |
| 141 | +let json = json!({ |
| 142 | + "null": { |
| 143 | + "pact:matcher:type": "null" |
| 144 | + }, |
| 145 | + "true": { |
| 146 | + "pact:matcher:type": "boolean", |
| 147 | + "value": true |
| 148 | + }, |
| 149 | + "false": { |
| 150 | + "pact:matcher:type": "boolean", |
| 151 | + "value": false |
| 152 | + }, |
| 153 | + "object": { |
| 154 | + "pact:matcher:type": "type", |
| 155 | + "value": { |
| 156 | + "key" => { |
| 157 | + "pact:matcher:type": "type", |
| 158 | + "value": "value" |
| 159 | + } |
| 160 | + } |
| 161 | + }, |
| 162 | + "array_of_arrays": { |
| 163 | + "pact:matcher:type": "type", |
| 164 | + "value": [["value1", "value2"]] |
| 165 | + }, |
| 166 | + "array_of_objects": { |
| 167 | + "pact:matcher:type": "type", |
| 168 | + "value": [{"key": "value"}] |
| 169 | + } |
| 170 | +}); |
| 171 | +pactffi_with_body(interaction, InteractionPart::Request, "application/x-www-form-urlencoded", json); |
| 172 | +``` |
| 173 | + |
| 174 | +These matchers and values are ignored. Unsupported error messages are logged. The extracted example body will be empty. |
| 175 | + |
| 176 | +#### Without matching rules: |
| 177 | + |
| 178 | +```rust |
| 179 | +let json = json!({ |
| 180 | + "null": null, |
| 181 | + "true": true, |
| 182 | + "false": false, |
| 183 | + "object": { |
| 184 | + "key": "value" |
| 185 | + }, |
| 186 | + "array_of_arrays": [["value1", "value2"]], |
| 187 | + "array_of_objects": [{"key": "value"}] |
| 188 | +}); |
| 189 | +pactffi_with_body(interaction, InteractionPart::Request, "application/x-www-form-urlencoded", json); |
| 190 | +``` |
| 191 | + |
| 192 | +The values are ignored. Unsupported error messages are logged. The extracted example body will be empty. |
| 193 | + |
| 194 | +### Special cases |
| 195 | + |
| 196 | +- keys with no values (`a=&b=&c=`): can be achieved by define empty string value |
| 197 | +- values with no keys (`=a&=b&=c`) |
| 198 | + - can be defined by json |
| 199 | + - can be defined by raw syntax |
| 200 | + - ordering does matter: |
| 201 | + - `=c&=a&=b` does not match `=a&=b&=c` |
| 202 | +- repeated keys with different ordering: (`a=1&a=2` vs `a=2&a=1`) |
| 203 | + - can be defined by json |
| 204 | + - can be defined by raw syntax |
| 205 | + - ordering does matter: |
| 206 | + - `a=1&a=2` does not match `a=2&a=1` |
| 207 | + - `a=2&a=1` does not match `a=1&a=2` |
| 208 | +- special characters (in key and value) |
| 209 | + - `/`: will be encoded to `%2F` |
| 210 | + - `&`: will be encoded to `%26` |
| 211 | + - `?`: will be encoded to `%3F` |
| 212 | + - `=`: will be encoded to `%3D` |
| 213 | +- only ampersands (`&&&`) |
| 214 | + - can't be defined by json |
| 215 | + - can be defined by raw syntax |
| 216 | + - these query strings can match it: |
| 217 | + - `&` |
| 218 | + - `&&` |
| 219 | + - `&&&` |
| 220 | + - ... |
| 221 | + - empty string can't match it |
| 222 | +- only equals signs (`===`) |
| 223 | + - can't be defined by json because all of these cases are detected as `text/plain` by pact: |
| 224 | + - `{"==":""}` |
| 225 | + - `{"=":"="}` |
| 226 | + - `{"":"=="}` |
| 227 | + - can be defined by raw syntax |
| 228 | + - these query strings can match it: |
| 229 | + - `===` |
| 230 | + - `=%3D%3D` |
| 231 | + - empty string can't match it |
| 232 | +- no key and value (`=&=&=`) |
| 233 | + - can be defined by json |
| 234 | + - can be defined by raw syntax |
| 235 | + - these query strings can't match it: |
| 236 | + - `` |
| 237 | + - `=&` |
| 238 | + - `=&=` |
| 239 | + - `=&=&` |
| 240 | + - `=&=&=&=` |
| 241 | + - `=&=&=&=&` |
| 242 | + - ... |
| 243 | + - these query strings can match it: |
| 244 | + - `=&=&=` |
| 245 | + - `=&=&=&` |
| 246 | +- repeated keys with empty string value and different ordering: (`a=&a=1` vs `a=1&a=` vs `a=1` vs `a=`) |
| 247 | + - can be defined by json |
| 248 | + - can be defined by raw syntax |
| 249 | + - ordering and size does matter: |
| 250 | + - `a=&a=1` does not match `a=1&a=` |
| 251 | + - `a=&a=1` does not match `a=1` |
| 252 | + - `a=&a=1` does not match `a=` |
| 253 | + - ... |
| 254 | + |
| 255 | +## Reference-level explanation |
| 256 | + |
| 257 | +Here is the flow we need to implement in Rust core (pact-reference project): |
| 258 | + |
| 259 | +- Pact implementation call FFI method `pactffi_with_body` with: |
| 260 | + - Content type: `application/x-www-form-urlencoded` |
| 261 | + - Body: Integration JSON format , which may include: |
| 262 | + - Matching rules |
| 263 | + - Example values |
| 264 | + - Generators |
| 265 | +- Rust core will process the body: |
| 266 | + - If content type hint is `application/x-www-form-urlencoded` AND detected content type from body is `application/json` |
| 267 | + - Extract matching rules |
| 268 | + - Extract generators |
| 269 | + - Return example JSON body with example values e.g. `{ "key": "example value" }` |
| 270 | + - Example JSON body will be converted to example Form UrlEncoded body e.g. `key=example+value` |
| 271 | + - These example values (including matchers and generators) will be ignored: |
| 272 | + - Null |
| 273 | + - Boolean (true/false) |
| 274 | + - Object |
| 275 | + - Array of Arrays |
| 276 | + - Array of Objects |
| 277 | + - Register the interaction as normal with: |
| 278 | + - Extracted matching rules |
| 279 | + - Extracted generators |
| 280 | + - Example Form UrlEncoded body |
| 281 | + - Content type: `application/x-www-form-urlencoded` |
| 282 | + |
| 283 | +## Drawbacks |
| 284 | + |
| 285 | +We need to modify Rust core (pact-reference project) |
| 286 | + |
| 287 | +## Rationale and alternatives |
| 288 | + |
| 289 | +Alternative approaches: |
| 290 | + |
| 291 | +### Hybrid syntax |
| 292 | + |
| 293 | +```rust |
| 294 | +let text = "number=matching(number, 123)&string=matching(regex, '\w\d', 'a1')&array=matching(eachValue(matching(regex, '\d{3}', '221')))" |
| 295 | +pactffi_with_body(interaction, InteractionPart::Request, "application/x-www-form-urlencoded", text); |
| 296 | +``` |
| 297 | + |
| 298 | +This is just a pseudocode. Parsing this syntax will be harder than Integration JSON format because we need to implement new code for it. |
| 299 | + |
| 300 | +### Plugin |
| 301 | + |
| 302 | +We can create a custom plugin to provide matching rules for `application/x-www-form-urlencoded` request. |
| 303 | + |
| 304 | +I already create a POC for it at https://github.com/tienvx/pact-form-urlencoded-plugin |
| 305 | + |
| 306 | +The problem with it are: |
| 307 | + |
| 308 | +* We need to move matchers from Rust core to this plugin. |
| 309 | +* The performance will be bad compare to implementing inside Rust core (pact-reference project) |
| 310 | + |
| 311 | +## Unresolved questions |
| 312 | + |
| 313 | +- There will be some bugs in Rust core need to be fix through the implementation of this RFC |
| 314 | +- Postel's law need to be followed outside of the implementation of this RFC |
| 315 | +- Due to lack of standards, some of the syntax will not be supported |
| 316 | + |
| 317 | +## Future possibilities |
| 318 | + |
| 319 | +### Support arrays and objects |
| 320 | + |
| 321 | +Probably not a good idea, because there are no standard syntax, and each language handle it differently |
| 322 | + |
| 323 | +Reference: https://blog.shalvah.me/posts/fun-stuff-representing-arrays-and-objects-in-query-strings |
| 324 | + |
| 325 | +### Follow Postel's law |
| 326 | + |
| 327 | +Following Postel's law, the provider may return fields that the consumer will just ignore. But if provider send extra param in body, we will got this mismatch: |
| 328 | + |
| 329 | +``` |
| 330 | +Unexpected form post parameter 'extra' received |
| 331 | +``` |
| 332 | + |
| 333 | +I believe this is a bug, and it happen independently with this RFC, we need to fix it in the future PRs. |
0 commit comments