├── .github └── workflows │ ├── test.yml │ └── trigger_partner_docs_update.yml ├── .gitignore ├── DIFF.md ├── LEARNING.md ├── README.md ├── consumer ├── .env ├── .eslintrc.json ├── package-lock.json ├── package.json ├── public │ ├── index.html │ └── sad_panda.gif └── src │ ├── App.css │ ├── App.js │ ├── ErrorBoundary.js │ ├── Heading.js │ ├── Layout.js │ ├── ProductPage.js │ ├── api.js │ ├── api.pact.spec.js │ ├── api.spec.js │ ├── index.css │ ├── index.js │ └── logo.svg ├── diagrams ├── workshop_step1.svg ├── workshop_step10_broker.svg ├── workshop_step1_class-sequence-diagram.svg ├── workshop_step1_failed_page.png ├── workshop_step2_failed_page.png ├── workshop_step2_unit_test.svg ├── workshop_step3_pact.svg ├── workshop_step4_pact.svg └── workshop_step5_pact.svg ├── docker-compose.yaml ├── package-lock.json ├── package.json └── provider ├── .eslintrc.json ├── middleware └── auth.middleware.js ├── package-lock.json ├── package.json ├── product ├── product.controller.js ├── product.js ├── product.pact.test.js ├── product.repository.js └── product.routes.js └── server.js /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: '20' 20 | - run: docker compose up -d 21 | - run: npm i 22 | - run: npm test --prefix consumer 23 | - run: npm run pact:publish --prefix consumer 24 | - run: npm test --prefix provider 25 | -------------------------------------------------------------------------------- /.github/workflows/trigger_partner_docs_update.yml: -------------------------------------------------------------------------------- 1 | name: Trigger update to partners.pactflow.io 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - '**.md' 9 | 10 | jobs: 11 | run: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Trigger docs.pact.io update workflow 15 | run: | 16 | curl -X POST https://api.github.com/repos/pactflow/partners.pactflow.io/dispatches \ 17 | -H 'Accept: application/vnd.github.everest-preview+json' \ 18 | -H "Authorization: Bearer $GITHUB_TOKEN" \ 19 | -d '{"event_type": "pact-workshop-js-updated"}' 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GHTOKENFORTRIGGERINGPACTDOCSUPDATE }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .idea/ 4 | 5 | # dependencies 6 | node_modules/ 7 | .pnp/ 8 | .pnp.js 9 | 10 | # testing 11 | coverage 12 | 13 | # production 14 | build/ 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | pacts/ 28 | logs/ -------------------------------------------------------------------------------- /DIFF.md: -------------------------------------------------------------------------------- 1 | # Here are the diffs between each step 2 | 3 | [step 1 -> step 2](https://github.com/pact-foundation/pact-workshop-js/compare/step1...step2) 4 | 5 | [step 2 -> step 3](https://github.com/pact-foundation/pact-workshop-js/compare/step2...step3) 6 | 7 | [step 3 -> step 4](https://github.com/pact-foundation/pact-workshop-js/compare/step3...step4) 8 | 9 | [step 4 -> step 5](https://github.com/pact-foundation/pact-workshop-js/compare/step4...step5) 10 | 11 | [step 5 -> step 6](https://github.com/pact-foundation/pact-workshop-js/compare/step5...step6) 12 | 13 | [step 6 -> step 7](https://github.com/pact-foundation/pact-workshop-js/compare/step6...step7) 14 | 15 | [step 7 -> step 8](https://github.com/pact-foundation/pact-workshop-js/compare/step7...step8) 16 | 17 | [step 8 -> step 9](https://github.com/pact-foundation/pact-workshop-js/compare/step8...step9) 18 | 19 | [step 9 -> step 10](https://github.com/pact-foundation/pact-workshop-js/compare/step9...step10) 20 | 21 | [step 10 -> step 11](https://github.com/pact-foundation/pact-workshop-js/compare/step10...step11) 22 | -------------------------------------------------------------------------------- /LEARNING.md: -------------------------------------------------------------------------------- 1 | # Learning Outcomes 2 | 3 | 4 | | Step | Title | Concept Covered | Learning objectives | Further Reading | 5 | |----------------------------------------------------------------------|---------------------------------------------------------|------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| 6 | | [step 1](https://github.com/pact-foundation/pact-workshop-js/tree/step1) | Create our consumer before the Provider API even exists | Consumer-driven design | | | | 7 | | [step 2](https://github.com/pact-foundation/pact-workshop-js/tree/step2) | Write a unit test for our consumer | - | | | 8 | | [step 3](https://github.com/pact-foundation/pact-workshop-js/tree/step3) | Write a Pact test for our consumer | Consumer side pact test | | | | 9 | | [step 4](https://github.com/pact-foundation/pact-workshop-js/tree/step4) | Verify the consumer pact with the Provider API | Provider side pact test | | | 10 | | [step 5](https://github.com/pact-foundation/pact-workshop-js/tree/step5) | Fix the consumer's bad assumptions about the Provider | Humans talking to humans (collaboration) | | | 11 | | [step 6](https://github.com/pact-foundation/pact-workshop-js/tree/step6) | Write a pact test for `404` (missing User) in consumer | Testing API invariants | | | 12 | | [step 7](https://github.com/pact-foundation/pact-workshop-js/tree/step7) | Update API to handle `404` case | Provider States | | | 13 | | [step 8](https://github.com/pact-foundation/pact-workshop-js/tree/step8) | Write a pact test for the `401` case | Testing authenticated APIs | | | 14 | | [step 9](https://github.com/pact-foundation/pact-workshop-js/tree/step9) | Update API to handle `401` case | Service evolution | | | 15 | | [step 10](https://github.com/pact-foundation/pact-workshop-js/tree/step10) | Fix the provider to support the `401` case | Request filters | | | 16 | | [step 11](https://github.com/pact-foundation/pact-workshop-js/tree/step11) | Implement a broker workflow for integration with CI/CD | Automation | | | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pact JS workshop 2 | 3 | ## Introduction 4 | 5 | This workshop is aimed at demonstrating core features and benefits of contract testing with Pact. 6 | 7 | Whilst contract testing can be applied retrospectively to systems, we will follow the [consumer driven contracts](https://martinfowler.com/articles/consumerDrivenContracts.html) approach in this workshop - where a new consumer and provider are created in parallel to evolve a service over time, especially where there is some uncertainty with what is to be built. 8 | 9 | This workshop should take from 1 to 2 hours, depending on how deep you want to go into each topic. 10 | 11 | **Workshop outline**: 12 | 13 | - [step 1: **create consumer**](https://github.com/pact-foundation/pact-workshop-js/tree/step1#step-1---simple-consumer-calling-provider): Create our consumer before the Provider API even exists 14 | - [step 2: **unit test**](https://github.com/pact-foundation/pact-workshop-js/tree/step2#step-2---client-tested-but-integration-fails): Write a unit test for our consumer 15 | - [step 3: **pact test**](https://github.com/pact-foundation/pact-workshop-js/tree/step3#step-3---pact-to-the-rescue): Write a Pact test for our consumer 16 | - [step 4: **pact verification**](https://github.com/pact-foundation/pact-workshop-js/tree/step4#step-4---verify-the-provider): Verify the consumer pact with the Provider API 17 | - [step 5: **fix consumer**](https://github.com/pact-foundation/pact-workshop-js/tree/step5#step-5---back-to-the-client-we-go): Fix the consumer's bad assumptions about the Provider 18 | - [step 6: **pact test**](https://github.com/pact-foundation/pact-workshop-js/tree/step6#step-6---consumer-updates-contract-for-missing-products): Write a pact test for `404` (missing User) in consumer 19 | - [step 7: **provider states**](https://github.com/pact-foundation/pact-workshop-js/tree/step7#step-7---adding-the-missing-states): Update API to handle `404` case 20 | - [step 8: **pact test**](https://github.com/pact-foundation/pact-workshop-js/tree/step8#step-8---authorisation): Write a pact test for the `401` case 21 | - [step 9: **pact test**](https://github.com/pact-foundation/pact-workshop-js/tree/step9#step-9---implement-authorisation-on-the-provider): Update API to handle `401` case 22 | - [step 10: **request filters**](https://github.com/pact-foundation/pact-workshop-js/tree/step10#step-10---request-filters-on-the-provider): Fix the provider to support the `401` case 23 | - [step 11: **pact broker**](https://github.com/pact-foundation/pact-workshop-js/tree/step11#step-11---using-a-pact-broker): Implement a broker workflow for integration with CI/CD 24 | - [step 12: **broker webhooks**](https://github.com/pact-foundation/pact-workshop-js/tree/step12#step-12---using-webhooks): Trigger provider workflows when contracts change, via webhooks 25 | - [step 13: **pactflow broker**](https://github.com/pact-foundation/pact-workshop-js/tree/step13#step-13---using-a-pactflow-broker): Implement a managed pactflow workflow for integration with CI/CD 26 | 27 | _NOTE: Each step is tied to, and must be run within, a git branch, allowing you to progress through each stage incrementally._ 28 | 29 | _EG: Move to step 2:_ 30 | 31 | _`git checkout step2`_ 32 | 33 | _`npm install`_ 34 | 35 |
36 | 37 | ## Learning objectives 38 | 39 | If running this as a team workshop format, you may want to take a look through the [learning objectives](./LEARNING.md). 40 | 41 | ## Requirements 42 | 43 | [Docker](https://www.docker.com) 44 | 45 | [Docker Compose](https://docs.docker.com/compose/install/) 46 | 47 | [Node + NPM](https://nodejs.org/en/) 48 | 49 | ## Scenario 50 | 51 | There are two components in scope for our workshop. 52 | 53 | 1. Product Catalog website. It provides an interface to query the Product service for product information. 54 | 1. Product Service (Provider). Provides useful things about products, such as listing all products and getting the details of an individual product. 55 | 56 | ## Step 1 - Simple Consumer calling Provider 57 | 58 | We need to first create an HTTP client to make the calls to our provider service: 59 | 60 | ![Simple Consumer](diagrams/workshop_step1.svg) 61 | 62 | The Consumer has implemented the product service client which has the following: 63 | 64 | - `GET /products` - Retrieve all products 65 | - `GET /products/{id}` - Retrieve a single product by ID 66 | 67 | The diagram below highlights the interaction for retrieving a product with ID 10: 68 | 69 | ![Sequence Diagram](diagrams/workshop_step1_class-sequence-diagram.svg) 70 | 71 | You can see the client interface we created in `consumer/src/api.js`: 72 | 73 | ```javascript 74 | export class API { 75 | 76 | constructor(url) { 77 | if (url === undefined || url === "") { 78 | url = process.env.REACT_APP_API_BASE_URL; 79 | } 80 | if (url.endsWith("/")) { 81 | url = url.substr(0, url.length - 1) 82 | } 83 | this.url = url 84 | } 85 | 86 | withPath(path) { 87 | if (!path.startsWith("/")) { 88 | path = "/" + path 89 | } 90 | return `${this.url}${path}` 91 | } 92 | 93 | async getAllProducts() { 94 | return axios.get(this.withPath("/products")) 95 | .then(r => r.data); 96 | } 97 | 98 | async getProduct(id) { 99 | return axios.get(this.withPath("/products/" + id)) 100 | .then(r => r.data); 101 | } 102 | } 103 | ``` 104 | 105 | After forking or cloning the repository, we may want to install the dependencies `npm install`. 106 | We can run the client with `npm start --prefix consumer` - it should fail with the error below, because the Provider is not running. 107 | 108 | ![Failed step1 page](diagrams/workshop_step1_failed_page.png) 109 | 110 | *Move on to [step 2](https://github.com/pact-foundation/pact-workshop-js/tree/step2#step-2---client-tested-but-integration-fails)* 111 | 112 | ## Step 2 - Client Tested but integration fails 113 | 114 | _NOTE: Move to step 2:_ 115 | 116 | _`git checkout step2`_ 117 | 118 | _`npm install`_ 119 | 120 |
121 | 122 | Now lets create a basic test for our API client. We're going to check 2 things: 123 | 124 | 1. That our client code hits the expected endpoint 125 | 2. That the response is marshalled into an object that is usable, with the correct ID 126 | 127 | You can see the client interface test we created in `consumer/src/api.spec.js`: 128 | 129 | ```javascript 130 | import API from "./api"; 131 | import nock from "nock"; 132 | 133 | describe("API", () => { 134 | 135 | test("get all products", async () => { 136 | const products = [ 137 | { 138 | "id": "9", 139 | "type": "CREDIT_CARD", 140 | "name": "GEM Visa", 141 | "version": "v2" 142 | }, 143 | { 144 | "id": "10", 145 | "type": "CREDIT_CARD", 146 | "name": "28 Degrees", 147 | "version": "v1" 148 | } 149 | ]; 150 | nock(API.url) 151 | .get('/products') 152 | .reply(200, 153 | products, 154 | {'Access-Control-Allow-Origin': '*'}); 155 | const respProducts = await API.getAllProducts(); 156 | expect(respProducts).toEqual(products); 157 | }); 158 | 159 | test("get product ID 50", async () => { 160 | const product = { 161 | "id": "50", 162 | "type": "CREDIT_CARD", 163 | "name": "28 Degrees", 164 | "version": "v1" 165 | }; 166 | nock(API.url) 167 | .get('/products/50') 168 | .reply(200, product, {'Access-Control-Allow-Origin': '*'}); 169 | const respProduct = await API.getProduct("50"); 170 | expect(respProduct).toEqual(product); 171 | }); 172 | }); 173 | ``` 174 | 175 | 176 | 177 | ![Unit Test With Mocked Response](diagrams/workshop_step2_unit_test.svg) 178 | 179 | 180 | 181 | Let's run this test and see it all pass: 182 | 183 | ```console 184 | ❯ npm test --prefix consumer 185 | 186 | PASS src/api.spec.js 187 | API 188 | ✓ get all products (15ms) 189 | ✓ get product ID 50 (3ms) 190 | 191 | Test Suites: 1 passed, 1 total 192 | Tests: 2 passed, 2 total 193 | Snapshots: 0 total 194 | Time: 1.03s 195 | Ran all test suites. 196 | ``` 197 | 198 | If you encounter failing tests after running `npm test --prefix consumer`, make sure that the current branch is `step2`. 199 | 200 | Meanwhile, our provider team has started building out their API in parallel. Let's run our website against our provider (you'll need two terminals to do this): 201 | 202 | 203 | ```console 204 | # Terminal 1 205 | ❯ npm start --prefix provider 206 | 207 | Provider API listening on port 8080... 208 | ``` 209 | 210 | ```console 211 | # Terminal 2 212 | > npm start --prefix consumer 213 | 214 | Compiled successfully! 215 | 216 | You can now view pact-workshop-js in the browser. 217 | 218 | Local: http://127.0.0.1:3000/ 219 | On Your Network: http://192.168.20.17:3000/ 220 | 221 | Note that the development build is not optimized. 222 | To create a production build, use npm run build. 223 | ``` 224 | 225 | You should now see a screen showing 3 different products. There is a `See more!` button which should display detailed product information. 226 | 227 | Let's see what happens! 228 | 229 | ![Failed page](diagrams/workshop_step2_failed_page.png) 230 | 231 | Doh! We are getting 404 everytime we try to view detailed product information. On closer inspection, the provider only knows about `/product/{id}` and `/products`. 232 | 233 | We need to have a conversation about what the endpoint should be, but first... 234 | 235 | *Move on to [step 3](https://github.com/pact-foundation/pact-workshop-js/tree/step3#step-3---pact-to-the-rescue)* 236 | 237 | ## Step 3 - Pact to the rescue 238 | 239 | _NOTE: Move to step 3:_ 240 | 241 | _`git checkout step3`_ 242 | 243 | _`npm install`_ 244 | 245 |
246 | 247 | Unit tests are written and executed in isolation of any other services. When we write tests for code that talk to other services, they are built on trust that the contracts are upheld. There is no way to validate that the consumer and provider can communicate correctly. 248 | 249 | > An integration contract test is a test at the boundary of an external service verifying that it meets the contract expected by a consuming service — [Martin Fowler](https://martinfowler.com/bliki/IntegrationContractTest.html) 250 | 251 | Adding contract tests via Pact would have highlighted the `/product/{id}` endpoint was incorrect. 252 | 253 | Let us add Pact to the project and write a consumer pact test for the `GET /products/{id}` endpoint. 254 | 255 | *Provider states* is an important concept of Pact that we need to introduce. These states help define the state that the provider should be in for specific interactions. For the moment, we will initially be testing the following states: 256 | 257 | - `product with ID 10 exists` 258 | - `products exist` 259 | 260 | The consumer can define the state of an interaction using the `given` property. 261 | 262 | Note how similar it looks to our unit test: 263 | 264 | In `consumer/src/api.pact.spec.js`: 265 | 266 | ```javascript 267 | import path from "path"; 268 | import { PactV3, MatchersV3, SpecificationVersion, } from "@pact-foundation/pact"; 269 | import { API } from "./api"; 270 | const { eachLike, like } = MatchersV3; 271 | 272 | const provider = new PactV3({ 273 | consumer: "FrontendWebsite", 274 | provider: "ProductService", 275 | log: path.resolve(process.cwd(), "logs", "pact.log"), 276 | logLevel: "warn", 277 | dir: path.resolve(process.cwd(), "pacts"), 278 | spec: SpecificationVersion.SPECIFICATION_VERSION_V2, 279 | host: "127.0.0.1" 280 | }); 281 | 282 | describe("API Pact test", () => { 283 | describe("getting all products", () => { 284 | test("products exists", async () => { 285 | // set up Pact interactions 286 | await provider.addInteraction({ 287 | states: [{ description: "products exist" }], 288 | uponReceiving: "get all products", 289 | withRequest: { 290 | method: "GET", 291 | path: "/products", 292 | }, 293 | willRespondWith: { 294 | status: 200, 295 | headers: { 296 | "Content-Type": "application/json; charset=utf-8", 297 | }, 298 | body: eachLike({ 299 | id: "09", 300 | type: "CREDIT_CARD", 301 | name: "Gem Visa", 302 | }), 303 | }, 304 | }); 305 | 306 | await provider.executeTest(async (mockService) => { 307 | const api = new API(mockService.url); 308 | 309 | // make request to Pact mock server 310 | const product = await api.getAllProducts(); 311 | 312 | expect(product).toStrictEqual([ 313 | { id: "09", name: "Gem Visa", type: "CREDIT_CARD" }, 314 | ]); 315 | }); 316 | }); 317 | }); 318 | 319 | describe("getting one product", () => { 320 | test("ID 10 exists", async () => { 321 | // set up Pact interactions 322 | await provider.addInteraction({ 323 | states: [{ description: "product with ID 10 exists" }], 324 | uponReceiving: "get product with ID 10", 325 | withRequest: { 326 | method: "GET", 327 | path: "/products/10", 328 | }, 329 | willRespondWith: { 330 | status: 200, 331 | headers: { 332 | "Content-Type": "application/json; charset=utf-8", 333 | }, 334 | body: like({ 335 | id: "10", 336 | type: "CREDIT_CARD", 337 | name: "28 Degrees", 338 | }), 339 | }, 340 | }); 341 | 342 | await provider.executeTest(async (mockService) => { 343 | const api = new API(mockService.url); 344 | 345 | // make request to Pact mock server 346 | const product = await api.getProduct("10"); 347 | 348 | expect(product).toStrictEqual({ 349 | id: "10", 350 | type: "CREDIT_CARD", 351 | name: "28 Degrees", 352 | }); 353 | }); 354 | }); 355 | }); 356 | }); 357 | ``` 358 | 359 | 360 | ![Test using Pact](diagrams/workshop_step3_pact.svg) 361 | 362 | This test starts a mock server a random port that acts as our provider service. To get this to work we update the URL in the `Client` that we create, after initialising Pact. 363 | 364 | To simplify running the tests, add this to `consumer/package.json`: 365 | 366 | ```javascript 367 | // add it under scripts 368 | "test:pact": "cross-env CI=true react-scripts test --testTimeout 30000 pact.spec.js", 369 | ``` 370 | 371 | Running this test still passes, but it creates a pact file which we can use to validate our assumptions on the provider side, and have conversation around. 372 | 373 | ```console 374 | ❯ npm run test:pact --prefix consumer 375 | 376 | PASS src/api.spec.js 377 | PASS src/api.pact.spec.js 378 | 379 | Test Suites: 2 passed, 2 total 380 | Tests: 4 passed, 4 total 381 | Snapshots: 0 total 382 | Time: 2.792s, estimated 3s 383 | Ran all test suites. 384 | ``` 385 | 386 | A pact file should have been generated in *consumer/pacts/FrontendWebsite-ProductService.json* 387 | 388 | *NOTE*: even if the API client had been graciously provided for us by our Provider Team, it doesn't mean that we shouldn't write contract tests - because the version of the client we have may not always be in sync with the deployed API - and also because we will write tests on the output appropriate to our specific needs. 389 | 390 | *Move on to [step 4](https://github.com/pact-foundation/pact-workshop-js/tree/step4#step-4---verify-the-provider)* 391 | 392 | ## Step 4 - Verify the provider 393 | 394 | _NOTE: Move to step 4:_ 395 | 396 | _`git checkout step4`_ 397 | 398 | _`npm install`_ 399 | 400 |
401 | 402 | We need to make the pact file (the contract) that was produced from the consumer test available to the Provider module. This will help us verify that the provider can meet the requirements as set out in the contract. For now, we'll hard code the path to where it is saved in the consumer test (as part of the [bronze step of Pact Nirvana](https://docs.pact.io/pact_nirvana/step_3#manually-run-the-provider-verification-test)), in step 11 we investigate a better way of doing this by dynamically fetching pacts from the broker. 403 | 404 | Now let's make a start on writing Pact tests to validate the consumer contract: 405 | 406 | In `provider/product/product.pact.test.js`: 407 | 408 | ```javascript 409 | const { Verifier } = require('@pact-foundation/pact'); 410 | const path = require('path'); 411 | 412 | // Setup provider server to verify 413 | const app = require('express')(); 414 | app.use(require('./product.routes')); 415 | const server = app.listen("8080"); 416 | 417 | describe("Pact Verification", () => { 418 | it("validates the expectations of ProductService", () => { 419 | const opts = { 420 | logLevel: "INFO", 421 | providerBaseUrl: "http://127.0.0.1:8080", 422 | provider: "ProductService", 423 | providerVersion: "1.0.0", 424 | pactUrls: [ 425 | path.resolve(__dirname, '../../consumer/pacts/FrontendWebsite-ProductService.json') 426 | ] 427 | }; 428 | 429 | return new Verifier(opts).verifyProvider().then(output => { 430 | console.log(output); 431 | }).finally(() => { 432 | server.close(); 433 | }); 434 | }) 435 | }); 436 | ``` 437 | 438 | To simplify running the tests, add this to `provider/package.json`: 439 | 440 | ```javascript 441 | // add it under scripts 442 | "test:pact": "jest --testTimeout=30000 --testMatch \"**/*.pact.test.js\"" 443 | ``` 444 | 445 | We now need to validate the pact generated by the consumer is valid, by executing it against the running service provider, which should fail: 446 | 447 | ```console 448 | ❯ npm run test:pact --prefix provider 449 | 450 | Verifying a pact between FrontendWebsite and ProductService 451 | 452 | get product with ID 10 453 | returns a response which 454 | has status code 200 (FAILED) 455 | includes headers 456 | "Content-Type" with value "application/json; charset=utf-8" (FAILED) 457 | has a matching body (FAILED) 458 | 459 | get all products 460 | returns a response which 461 | has status code 200 (OK) 462 | includes headers 463 | "Content-Type" with value "application/json; charset=utf-8" (OK) 464 | has a matching body (OK) 465 | 466 | 467 | Failures: 468 | 469 | 1) Verifying a pact between FrontendWebsite and ProductService Given product with ID 10 exists - get product with ID 10 470 | 1.1) has a matching body 471 | expected 'application/json;charset=utf-8' body but was 'text/html;charset=utf-8' 472 | 1.2) has status code 200 473 | expected 200 but was 404 474 | 1.3) includes header 'Content-Type' with value 'application/json; charset=utf-8' 475 | Expected header 'Content-Type' to have value 'application/json; charset=utf-8' but was 'text/html; charset=utf-8' 476 | ``` 477 | 478 | ![Pact Verification](diagrams/workshop_step4_pact.svg) 479 | 480 | The test has failed, as the expected path `/products/{id}` is returning 404. We incorrectly believed our provider was following a RESTful design, but the authors were too lazy to implement a better routing solution 🤷🏻‍♂️. 481 | 482 | The correct endpoint which the consumer should call is `/product/{id}`. 483 | 484 | Move on to [step 5](https://github.com/pact-foundation/pact-workshop-js/tree/step5#step-5---back-to-the-client-we-go) 485 | 486 | ## Step 5 - Back to the client we go 487 | 488 | _NOTE: Move to step 5:_ 489 | 490 | _`git checkout step5`_ 491 | 492 | _`npm install`_ 493 | 494 |
495 | 496 | We now need to update the consumer client and tests to hit the correct product path. 497 | 498 | First, we need to update the GET route for the client: 499 | 500 | In `consumer/src/api.js`: 501 | 502 | ```javascript 503 | async getProduct(id) { 504 | return axios.get(this.withPath("/product/" + id)) 505 | .then(r => r.data); 506 | } 507 | ``` 508 | 509 | Then we need to update the Pact test `ID 10 exists` to use the correct endpoint in `path`. 510 | 511 | In `consumer/src/api.pact.spec.js`: 512 | 513 | ```javascript 514 | describe("getting one product", () => { 515 | test("ID 10 exists", async () => { 516 | 517 | // set up Pact interactions 518 | await provider.addInteraction({ 519 | state: 'product with ID 10 exists', 520 | uponReceiving: 'get product with ID 10', 521 | withRequest: { 522 | method: 'GET', 523 | path: '/product/10' 524 | }, 525 | 526 | ... 527 | ``` 528 | 529 | ![Pact Verification](diagrams/workshop_step5_pact.svg) 530 | 531 | Let's run and generate an updated pact file on the client: 532 | 533 | ```console 534 | ❯ npm run test:pact --prefix consumer 535 | 536 | PASS src/api.pact.spec.js 537 | API Pact test 538 | getting all products 539 | ✓ products exists (18ms) 540 | getting one product 541 | ✓ ID 10 exists (8ms) 542 | 543 | Test Suites: 1 passed, 1 total 544 | Tests: 2 passed, 2 total 545 | Snapshots: 0 total 546 | Time: 2.106s 547 | Ran all test suites matching /pact.spec.js/i. 548 | ``` 549 | 550 | 551 | 552 | Now we run the provider tests again with the updated contract: 553 | 554 | Run the command: 555 | 556 | ```console 557 | ❯ npm run test:pact --prefix provider 558 | 559 | Verifying a pact between FrontendWebsite and ProductService 560 | 561 | get product with ID 10 562 | returns a response which 563 | has status code 200 (OK) 564 | includes headers 565 | "Content-Type" with value "application/json; charset=utf-8" (OK) 566 | has a matching body (OK) 567 | 568 | get all products 569 | returns a response which 570 | has status code 200 (OK) 571 | includes headers 572 | "Content-Type" with value "application/json; charset=utf-8" (OK) 573 | has a matching body (OK) 574 | ``` 575 | 576 | Yay - green ✅! 577 | 578 | Move on to [step 6](https://github.com/pact-foundation/pact-workshop-js/tree/step6#step-6---consumer-updates-contract-for-missing-products) 579 | 580 | ## Step 6 - Consumer updates contract for missing products 581 | 582 | _NOTE: Move to step 6:_ 583 | 584 | _`git checkout step6`_ 585 | 586 | _`npm install`_ 587 | 588 |
589 | 590 | We're now going to add 2 more scenarios for the contract 591 | 592 | - What happens when we make a call for a product that doesn't exist? We assume we'll get a `404`. 593 | 594 | - What happens when we make a call for getting all products but none exist at the moment? We assume a `200` with an empty array. 595 | 596 | Let's write a test for these scenarios, and then generate an updated pact file. 597 | 598 | In `consumer/src/api.pact.spec.js`: 599 | 600 | ```javascript 601 | // within the 'getting all products' group 602 | test("no products exists", async () => { 603 | 604 | // set up Pact interactions 605 | await provider.addInteraction({ 606 | state: 'no products exist', 607 | uponReceiving: 'get all products', 608 | withRequest: { 609 | method: 'GET', 610 | path: '/products' 611 | }, 612 | willRespondWith: { 613 | status: 200, 614 | headers: { 615 | 'Content-Type': 'application/json; charset=utf-8' 616 | }, 617 | body: [] 618 | }, 619 | }); 620 | 621 | const api = new API(provider.mockService.baseUrl); 622 | 623 | // make request to Pact mock server 624 | const product = await api.getAllProducts(); 625 | 626 | expect(product).toStrictEqual([]); 627 | }); 628 | 629 | // within the 'getting one product' group 630 | test("product does not exist", async () => { 631 | 632 | // set up Pact interactions 633 | await provider.addInteraction({ 634 | state: 'product with ID 11 does not exist', 635 | uponReceiving: 'get product with ID 11', 636 | withRequest: { 637 | method: 'GET', 638 | path: '/product/11' 639 | }, 640 | willRespondWith: { 641 | status: 404 642 | }, 643 | }); 644 | 645 | await provider.executeTest(async (mockService) => { 646 | const api = new API(mockService.url); 647 | 648 | // make request to Pact mock server 649 | await expect(api.getProduct("11")).rejects.toThrow( 650 | "Request failed with status code 404" 651 | ); 652 | }); 653 | }); 654 | ``` 655 | 656 | Notice that our new tests look almost identical to our previous tests, and only differ on the expectations of the _response_ - the HTTP request expectations are exactly the same. 657 | 658 | ```console 659 | ❯ npm run test:pact --prefix consumer 660 | 661 | PASS src/api.pact.spec.js 662 | API Pact test 663 | getting all products 664 | ✓ products exists (24ms) 665 | ✓ no products exists (13ms) 666 | getting one product 667 | ✓ ID 10 exists (14ms) 668 | ✓ product does not exist (14ms) 669 | 670 | Test Suites: 1 passed, 1 total 671 | Tests: 4 passed, 4 total 672 | Snapshots: 0 total 673 | Time: 2.437s, estimated 3s 674 | Ran all test suites matching /pact.spec.js/i. 675 | 676 | ``` 677 | 678 | What does our provider have to say about this new test: 679 | 680 | ```console 681 | ❯ npm run test:pact --prefix provider 682 | 683 | Verifying a pact between FrontendWebsite and ProductService 684 | 685 | get all products 686 | returns a response which 687 | has status code 200 (OK) 688 | includes headers 689 | "Content-Type" with value "application/json; charset=utf-8" (OK) 690 | has a matching body (FAILED) 691 | 692 | get product with ID 10 693 | returns a response which 694 | has status code 200 (OK) 695 | includes headers 696 | "Content-Type" with value "application/json; charset=utf-8" (OK) 697 | has a matching body (OK) 698 | 699 | get product with ID 11 700 | returns a response which 701 | has status code 404 (FAILED) 702 | has a matching body (OK) 703 | 704 | get all products 705 | returns a response which 706 | has status code 200 (OK) 707 | includes headers 708 | "Content-Type" with value "application/json; charset=utf-8" (OK) 709 | has a matching body (OK) 710 | 711 | 712 | Failures: 713 | 714 | 1) Verifying a pact between FrontendWebsite and ProductService Given no products exist - get all products 715 | 1.1) has a matching body 716 | $ -> Expected an empty List but received [{"id":"09","name":"Gem Visa","type":"CREDIT_CARD","version":"v1"},{"id":"10","name":"28 Degrees","type":"CREDIT_CARD","version":"v1"},{"id":"11","name":"MyFlexiPay","type":"PERSONAL_LOAN","version":"v2"}] 717 | 2) Verifying a pact between FrontendWebsite and ProductService Given product with ID 11 does not exist - get product with ID 11 718 | 2.1) has status code 404 719 | expected 404 but was 200 720 | ``` 721 | 722 | We expected this failure, because the product we are requesing does in fact exist! What we want to test for, is what happens if there is a different *state* on the Provider. This is what is referred to as "Provider states", and how Pact gets around test ordering and related issues. 723 | 724 | We could resolve this by updating our consumer test to use a known non-existent product, but it's worth understanding how Provider states work more generally. 725 | 726 | *Move on to [step 7](https://github.com/pact-foundation/pact-workshop-js/tree/step7#step-7---adding-the-missing-states)* 727 | 728 | ## Step 7 - Adding the missing states 729 | 730 | _NOTE: Move to step 7:_ 731 | 732 | _`git checkout step7`_ 733 | 734 | _`npm install`_ 735 | 736 |
737 | 738 | Our code already deals with missing users and sends a `404` response, however our test data fixture always has product ID 10 and 11 in our database. 739 | 740 | In this step, we will add a state handler (`stateHandlers`) to our provider Pact verifications, which will update the state of our data store depending on which states the consumers require. 741 | 742 | States are invoked prior to the actual test function is invoked. You can see the full [lifecycle here](https://github.com/pact-foundation/pact-go#lifecycle-of-a-provider-verification). 743 | 744 | We're going to add handlers for all our states: 745 | 746 | - products exist 747 | - no products exist 748 | - product with ID 10 exists 749 | - product with ID 11 does not exist 750 | 751 | Let's open up our provider Pact verifications in `provider/product/product.pact.test.js`: 752 | 753 | ```javascript 754 | // add this to the Verifier opts 755 | stateHandlers: { 756 | "product with ID 10 exists": () => { 757 | controller.repository.products = new Map([ 758 | ["10", new Product("10", "CREDIT_CARD", "28 Degrees", "v1")] 759 | ]); 760 | }, 761 | "products exist": () => { 762 | controller.repository.products = new Map([ 763 | ["09", new Product("09", "CREDIT_CARD", "Gem Visa", "v1")], 764 | ["10", new Product("10", "CREDIT_CARD", "28 Degrees", "v1")] 765 | ]); 766 | }, 767 | "no products exist": () => { 768 | controller.repository.products = new Map(); 769 | }, 770 | "product with ID 11 does not exist": () => { 771 | controller.repository.products = new Map(); 772 | }, 773 | } 774 | ``` 775 | 776 | Let's see how we go now: 777 | 778 | ```console 779 | ❯ npm run test:pact --prefix provider 780 | 781 | Verifying a pact between FrontendWebsite and ProductService 782 | 783 | get all products 784 | returns a response which 785 | has status code 200 (OK) 786 | includes headers 787 | "Content-Type" with value "application/json; charset=utf-8" (OK) 788 | has a matching body (OK) 789 | 790 | get product with ID 10 791 | returns a response which 792 | has status code 200 (OK) 793 | includes headers 794 | "Content-Type" with value "application/json; charset=utf-8" (OK) 795 | has a matching body (OK) 796 | 797 | get product with ID 11 798 | returns a response which 799 | has status code 404 (OK) 800 | has a matching body (OK) 801 | 802 | get all products 803 | returns a response which 804 | has status code 200 (OK) 805 | includes headers 806 | "Content-Type" with value "application/json; charset=utf-8" (OK) 807 | has a matching body (OK) 808 | ``` 809 | 810 | _NOTE_: The states are not necessarily a 1 to 1 mapping with the consumer contract tests. You can reuse states amongst different tests. In this scenario we could have used `no products exist` for both tests which would have equally been valid. 811 | 812 | *Move on to [step 8](https://github.com/pact-foundation/pact-workshop-js/tree/step8#step-8---authorisation)* 813 | 814 | ## Step 8 - Authorisation 815 | 816 | _NOTE: Move to step 8:_ 817 | 818 | _`git checkout step8`_ 819 | 820 | _`npm install`_ 821 | 822 |
823 | 824 | It turns out that not everyone should be able to use the API. After a discussion with the team, it was decided that a time-bound bearer token would suffice. The token must be in `yyyy-MM-ddTHHmm` format and within 1 hour of the current time. 825 | 826 | In the case a valid bearer token is not provided, we expect a `401`. Let's update the consumer to pass the bearer token, and capture this new `401` scenario. 827 | 828 | In `consumer/src/api.js`: 829 | 830 | ```javascript 831 | generateAuthToken() { 832 | return "Bearer " + new Date().toISOString() 833 | } 834 | 835 | async getAllProducts() { 836 | return axios.get(this.withPath("/products"), { 837 | headers: { 838 | "Authorization": this.generateAuthToken() 839 | } 840 | }) 841 | .then(r => r.data); 842 | } 843 | 844 | async getProduct(id) { 845 | return axios.get(this.withPath("/product/" + id), { 846 | headers: { 847 | "Authorization": this.generateAuthToken() 848 | } 849 | }) 850 | .then(r => r.data); 851 | } 852 | ``` 853 | 854 | In `consumer/src/api.pact.spec.js` we add authentication headers to the request setup for the existing tests: 855 | 856 | ```js 857 | await provider.addInteraction({ 858 | states: [{ description: "no products exist" }], 859 | uponReceiving: "get all products", 860 | withRequest: { 861 | method: "GET", 862 | path: "/products", 863 | headers: { 864 | Authorization: like("Bearer 2019-01-14T11:34:18.045Z"), 865 | }, 866 | }, 867 | willRespondWith: { 868 | status: 200, 869 | headers: { 870 | "Content-Type": "application/json; charset=utf-8", 871 | }, 872 | body: [], 873 | }, 874 | }); 875 | ``` 876 | 877 | and we also add two new tests for the "no auth token" use case: 878 | 879 | ```js 880 | // ... 881 | test("no auth token", async () => { 882 | 883 | // set up Pact interactions 884 | await provider.addInteraction({ 885 | states: [{ description: "product with ID 10 exists" }], 886 | uponReceiving: "get product by ID 10 with no auth token", 887 | withRequest: { 888 | method: "GET", 889 | path: "/product/10", 890 | }, 891 | willRespondWith: { 892 | status: 401, 893 | }, 894 | }); 895 | 896 | await provider.executeTest(async (mockService) => { 897 | const api = new API(mockService.url); 898 | 899 | // make request to Pact mock server 900 | await expect(api.getProduct("10")).rejects.toThrow( 901 | "Request failed with status code 401" 902 | ); 903 | }); 904 | }); 905 | ``` 906 | 907 | Generate a new Pact file: 908 | 909 | ```console 910 | ❯ npm run test:pact --prefix consumer 911 | 912 | PASS src/api.pact.spec.js 913 | API Pact test 914 | getting all products 915 | ✓ products exists (23ms) 916 | ✓ no products exists (13ms) 917 | ✓ no auth token (14ms) 918 | getting one product 919 | ✓ ID 10 exists (12ms) 920 | ✓ product does not exist (12ms) 921 | ✓ no auth token (14ms) 922 | 923 | Test Suites: 1 passed, 1 total 924 | Tests: 6 passed, 6 total 925 | Snapshots: 0 total 926 | Time: 2.469s, estimated 3s 927 | Ran all test suites matching /pact.spec.js/i. 928 | ``` 929 | 930 | We should now have two new interactions in our pact file. 931 | 932 | Let's test the provider: 933 | 934 | ```console 935 | ❯ npm run test:pact --prefix provider 936 | 937 | Verifying a pact between FrontendWebsite and ProductService 938 | 939 | get all products 940 | returns a response which 941 | has status code 200 (OK) 942 | includes headers 943 | "Content-Type" with value "application/json; charset=utf-8" (OK) 944 | has a matching body (OK) 945 | 946 | get product by ID 10 with no auth token 947 | returns a response which 948 | has status code 401 (FAILED) 949 | has a matching body (OK) 950 | 951 | get product with ID 10 952 | returns a response which 953 | has status code 200 (OK) 954 | includes headers 955 | "Content-Type" with value "application/json; charset=utf-8" (OK) 956 | has a matching body (OK) 957 | 958 | get product with ID 11 959 | returns a response which 960 | has status code 404 (OK) 961 | has a matching body (OK) 962 | 963 | get all products 964 | returns a response which 965 | has status code 401 (FAILED) 966 | has a matching body (OK) 967 | 968 | 969 | Failures: 970 | 971 | 1) Verifying a pact between FrontendWebsite and ProductService Given product with ID 10 exists - get product by ID 10 with no auth token 972 | 1.1) has status code 401 973 | expected 401 but was 200 974 | 2) Verifying a pact between FrontendWebsite and ProductService Given products exist - get all products 975 | 2.1) has status code 401 976 | expected 401 but was 200 977 | ``` 978 | 979 | Now with the most recently added interactions where we are expecting a response of 401 when no authorization header is sent, we are getting 200... 980 | 981 | Move on to [step 9](https://github.com/pact-foundation/pact-workshop-js/tree/step9#step-9---implement-authorisation-on-the-provider)* 982 | 983 | ## Step 9 - Implement authorisation on the provider 984 | 985 | _NOTE: Move to step 9:_ 986 | 987 | _`git checkout step9`_ 988 | 989 | _`npm install`_ 990 | 991 |
992 | 993 | We will add a middleware to check the Authorization header and deny the request with `401` if the token is older than 1 hour. 994 | 995 | In `provider/middleware/auth.middleware.js` 996 | 997 | ```javascript 998 | // 'Token' should be a valid ISO 8601 timestamp within the last hour 999 | const isValidAuthTimestamp = (timestamp) => { 1000 | let diff = (new Date() - new Date(timestamp)) / 1000; 1001 | return diff >= 0 && diff <= 3600 1002 | }; 1003 | 1004 | const authMiddleware = (req, res, next) => { 1005 | if (!req.headers.authorization) { 1006 | return res.status(401).json({ error: "Unauthorized" }); 1007 | } 1008 | const timestamp = req.headers.authorization.replace("Bearer ", "") 1009 | if (!isValidAuthTimestamp(timestamp)) { 1010 | return res.status(401).json({ error: "Unauthorized" }); 1011 | } 1012 | next(); 1013 | }; 1014 | 1015 | module.exports = authMiddleware; 1016 | ``` 1017 | 1018 | In `provider/server.js` 1019 | 1020 | ```javascript 1021 | const authMiddleware = require('./middleware/auth.middleware'); 1022 | 1023 | // add this into your init function 1024 | app.use(authMiddleware); 1025 | ``` 1026 | 1027 | We also need to add the middleware to the server our Pact tests use. 1028 | 1029 | In `provider/product/product.pact.test.js`: 1030 | 1031 | ```javascript 1032 | const authMiddleware = require('../middleware/auth.middleware'); 1033 | app.use(authMiddleware); 1034 | ``` 1035 | 1036 | This means that a client must present an HTTP `Authorization` header that looks as follows: 1037 | 1038 | ``` 1039 | Authorization: Bearer 2006-01-02T15:04 1040 | ``` 1041 | 1042 | Let's test this out: 1043 | 1044 | ```console 1045 | ❯ npm run test:pact --prefix provider 1046 | 1047 | Verifying a pact between FrontendWebsite and ProductService 1048 | 1049 | get all products 1050 | returns a response which 1051 | has status code 200 (FAILED) 1052 | includes headers 1053 | "Content-Type" with value "application/json; charset=utf-8" (OK) 1054 | has a matching body (FAILED) 1055 | 1056 | get product by ID 10 with no auth token 1057 | returns a response which 1058 | has status code 401 (OK) 1059 | has a matching body (OK) 1060 | 1061 | get product with ID 10 1062 | returns a response which 1063 | has status code 200 (FAILED) 1064 | includes headers 1065 | "Content-Type" with value "application/json; charset=utf-8" (OK) 1066 | has a matching body (FAILED) 1067 | 1068 | get product with ID 11 1069 | returns a response which 1070 | has status code 404 (FAILED) 1071 | has a matching body (OK) 1072 | 1073 | get all products 1074 | returns a response which 1075 | has status code 401 (OK) 1076 | has a matching body (OK) 1077 | 1078 | 1079 | Failures: 1080 | 1081 | 1) Verifying a pact between FrontendWebsite and ProductService Given no products exist - get all products 1082 | 1.1) has a matching body 1083 | $ -> Type mismatch: Expected List [] but received Map {"error":"Unauthorized"} 1084 | 1.2) has status code 200 1085 | expected 200 but was 401 1086 | 2) Verifying a pact between FrontendWebsite and ProductService Given product with ID 10 exists - get product with ID 10 1087 | 2.1) has a matching body 1088 | $ -> Actual map is missing the following keys: id, name, type 1089 | 2.2) has status code 200 1090 | expected 200 but was 401 1091 | 3) Verifying a pact between FrontendWebsite and ProductService Given product with ID 11 does not exist - get product with ID 11 1092 | 3.1) has status code 404 1093 | expected 404 but was 401 1094 | 1095 | There were 3 pact failures 1096 | ``` 1097 | 1098 | Oh, dear. _More_ tests are failing. Can you understand why? 1099 | 1100 | *Move on to [step 10](https://github.com/pact-foundation/pact-workshop-js/tree/step10#step-10---request-filters-on-the-provider)* 1101 | 1102 | ## Step 10 - Request Filters on the Provider 1103 | 1104 | _NOTE: Move to step 10:_ 1105 | 1106 | _`git checkout step10`_ 1107 | 1108 | _`npm install`_ 1109 | 1110 |
1111 | 1112 | Because our pact file has static data in it, our bearer token is now out of date, so when Pact verification passes it to the Provider we get a `401`. There are multiple ways to resolve this - mocking or stubbing out the authentication component is a common one. In our use case, we are going to use a process referred to as _Request Filtering_, using a `RequestFilter`. 1113 | 1114 | _NOTE_: This is an advanced concept and should be used carefully, as it has the potential to invalidate a contract by bypassing its constraints. See https://github.com/DiUS/pact-jvm/blob/master/provider/junit/README.md#modifying-the-requests-before-they-are-sent for more details on this. 1115 | 1116 | The approach we are going to take to inject the header is as follows: 1117 | 1118 | 1. If we receive any Authorization header, we override the incoming request with a valid (in time) Authorization header, and continue with whatever call was being made 1119 | 1. If we don't receive an Authorization header, we do nothing 1120 | 1121 | _NOTE_: We are not considering the `403` scenario in this example. 1122 | 1123 | In `provider/product/product.pact.test.js`: 1124 | 1125 | ```javascript 1126 | // add this to the Verifier opts 1127 | requestFilter: (req, res, next) => { 1128 | if (!req.headers["authorization"]) { 1129 | next(); 1130 | return; 1131 | } 1132 | req.headers["authorization"] = `Bearer ${ new Date().toISOString() }`; 1133 | next(); 1134 | }, 1135 | ``` 1136 | 1137 | We can now run the Provider tests 1138 | 1139 | ```console 1140 | ❯ npm run test:pact --prefix provider 1141 | 1142 | 1143 | ``` 1144 | 1145 | *Move on to [step 11](https://github.com/pact-foundation/pact-workshop-js/tree/step11#step-11---using-a-pact-broker)* 1146 | 1147 | ## Step 11 - Using a Pact Broker 1148 | 1149 | _NOTE: Move to step 11:_ 1150 | 1151 | _`git checkout step11`_ 1152 | 1153 | _`npm install`_ 1154 | 1155 |
1156 | 1157 | ![Broker collaboration Workflow](diagrams/workshop_step10_broker.svg) 1158 | 1159 | We've been publishing our pacts from the consumer project by essentially sharing the file system with the provider. But this is not very manageable when you have multiple teams contributing to the code base, and pushing to CI. We can use a [Pact Broker](https://pactflow.io) to do this instead. 1160 | 1161 | Using a broker simplifies the management of pacts and adds a number of useful features, including some safety enhancements for continuous delivery which we'll see shortly. 1162 | 1163 | In this workshop we will be using the open source Pact broker. 1164 | 1165 | ### Running the Pact Broker with docker-compose 1166 | 1167 | In the root directory, run: 1168 | 1169 | ```console 1170 | docker-compose up 1171 | ``` 1172 | 1173 | ### Publish contracts from consumer 1174 | 1175 | First, in the consumer project we need to tell Pact about our broker. We can use the in built `pact-broker` CLI command to do this: 1176 | 1177 | ```javascript 1178 | // add this under scripts 1179 | "pact:publish": "pact-broker publish ./pacts --consumer-app-version=\"1.0.0\" --auto-detect-version-properties --broker-base-url=http://127.0.0.1:8000 --broker-username pact_workshop --broker-password pact_workshop" 1180 | ``` 1181 | 1182 | Now run 1183 | 1184 | ```console 1185 | ❯ npm run test:pact --prefix consumer 1186 | 1187 | PASS src/api.pact.spec.js 1188 | API Pact test 1189 | getting all products 1190 | ✓ products exists (22ms) 1191 | ✓ no products exists (12ms) 1192 | ✓ no auth token (13ms) 1193 | getting one product 1194 | ✓ ID 10 exists (11ms) 1195 | ✓ product does not exist (12ms) 1196 | ✓ no auth token (14ms) 1197 | 1198 | Test Suites: 1 passed, 1 total 1199 | Tests: 6 passed, 6 total 1200 | Snapshots: 0 total 1201 | Time: 2.653s 1202 | Ran all test suites matching /pact.spec.js/i. 1203 | ``` 1204 | 1205 | To publish the pacts: 1206 | 1207 | 1208 | ``` 1209 | ❯ npm run pact:publish --prefix consumer 1210 | 1211 | Created FrontendWebsite version 24c0e1-step11+24c0e1.SNAPSHOT.SB-AS-G7GM9F7 with branch step11 1212 | Pact successfully published for FrontendWebsite version 24c0e1-step11+24c0e1.SNAPSHOT.SB-AS-G7GM9F7 and provider ProductService. 1213 | View the published pact at http://127.0.0.1:8000/pacts/provider/ProductService/consumer/FrontendWebsite/version/24c0e1-step11%2B24c0e1.SNAPSHOT.SB-AS-G7GM9F7 1214 | Events detected: contract_published (pact content is the same as previous versions with tags and no new tags were applied) 1215 | Next steps: 1216 | * Configure separate ProductService pact verification build and webhook to trigger it when the pact content changes. See https://docs.pact.io/go/webhooks 1217 | ``` 1218 | 1219 | *NOTE: you would usually only publish pacts from CI. * 1220 | 1221 | Have a browse around the broker on http://127.0.0.1:8000 (with username/password: `pact_workshop`/`pact_workshop`) and see your newly published contract! 1222 | 1223 | ### Verify contracts on Provider 1224 | 1225 | All we need to do for the provider is update where it finds its pacts, from local URLs, to one from a broker. 1226 | 1227 | In `provider/product/product.pact.test.js`: 1228 | 1229 | ```javascript 1230 | //replace 1231 | pactUrls: [ 1232 | path.resolve(__dirname, '../pacts/FrontendWebsite-ProductService.json') 1233 | ], 1234 | 1235 | // with 1236 | pactBrokerUrl: process.env.PACT_BROKER_BASE_URL || "http://127.0.0.1:8000", 1237 | pactBrokerUsername: process.env.PACT_BROKER_USERNAME || "pact_workshop", 1238 | pactBrokerPassword: process.env.PACT_BROKER_PASSWORD || "pact_workshop", 1239 | ``` 1240 | 1241 | ```javascript 1242 | // add to the opts {...} 1243 | publishVerificationResult: process.env.CI || process.env.PACT_BROKER_PUBLISH_VERIFICATION_RESULTS 1244 | ``` 1245 | 1246 | Let's run the provider verification one last time after this change. It should print a few notices showing which pact(s) it has found from the broker, and why they were selected: 1247 | 1248 | ```console 1249 | ❯ PACT_BROKER_PUBLISH_VERIFICATION_RESULTS=true npm run test:pact --prefix provider 1250 | 1251 | The pact at http://127.0.0.1:8000/pacts/provider/ProductService/consumer/FrontendWebsite/pact-version/80d8e7379fc7d5cfe503665ec1776bfb139aa8cf is being verified because the pact content belongs to the consumer version matching the following criterion: 1252 | * latest version of FrontendWebsite that has a pact with ProductService (9cd950-step10+9cd950.SNAPSHOT.SB-AS-G7GM9F7) 1253 | 1254 | Verifying a pact between FrontendWebsite and ProductService 1255 | 1256 | get all products 1257 | returns a response which 1258 | has status code 200 (OK) 1259 | includes headers 1260 | "Content-Type" with value "application/json; charset=utf-8" (OK) 1261 | has a matching body (OK) 1262 | 1263 | get product by ID 10 with no auth token 1264 | returns a response which 1265 | has status code 401 (OK) 1266 | has a matching body (OK) 1267 | 1268 | get product with ID 10 1269 | returns a response which 1270 | has status code 200 (OK) 1271 | includes headers 1272 | "Content-Type" with value "application/json; charset=utf-8" (OK) 1273 | has a matching body (OK) 1274 | 1275 | get product with ID 11 1276 | returns a response which 1277 | has status code 404 (OK) 1278 | has a matching body (OK) 1279 | 1280 | get all products 1281 | returns a response which 1282 | has status code 401 (OK) 1283 | has a matching body (OK) 1284 | ``` 1285 | 1286 | As part of this process, the results of the verification - the outcome (boolean) and the detailed information about the failures at the interaction level - are published to the Broker also. 1287 | 1288 | This is one of the Broker's more powerful features. Referred to as [Verifications](https://docs.pact.io/pact_broker/advanced_topics/provider_verification_results), it allows providers to report back the status of a verification to the broker. You'll get a quick view of the status of each consumer and provider on a nice dashboard. But, it is much more important than this! 1289 | 1290 | ### Can I deploy? 1291 | 1292 | With just a simple use of the `pact-broker` [can-i-deploy tool](https://docs.pact.io/pact_broker/can_i_deploy) - the Broker will determine if a consumer or provider is safe to release to the specified environment. 1293 | 1294 | In this example, we will use the [pact-cli](https://docs.pact.io/implementation_guides/cli#distributions) tools which are contained in the pact-js package. 1295 | 1296 | This is why we use `npx` in our example. Ensure you are in the `consumer` or `provider` folder. Alternatively you can download the cli tools, to your machine and make it globally available or use it from a Docker container. 1297 | 1298 | You can run the `pact-broker can-i-deploy` checks as follows: 1299 | 1300 | ```console 1301 | ❯ cd consumer 1302 | ❯ npx pact-broker can-i-deploy \ 1303 | --pacticipant FrontendWebsite \ 1304 | --broker-base-url http://127.0.0.1:8000 \ 1305 | --broker-username pact_workshop \ 1306 | --broker-password pact_workshop \ 1307 | --latest 1308 | 1309 | Computer says yes \o/ 1310 | 1311 | CONSUMER | C.VERSION | PROVIDER | P.VERSION | SUCCESS? 1312 | ----------------|-----------|----------------|-----------|--------- 1313 | FrontendWebsite | fe0b6a3 | ProductService | 1.0.0 | true 1314 | 1315 | All required verification results are published and successful 1316 | 1317 | ---------------------------- 1318 | 1319 | ❯ cd consumer 1320 | ❯ npx pact-broker can-i-deploy \ 1321 | --pacticipant ProductService \ 1322 | --broker-base-url http://127.0.0.1:8000 \ 1323 | --broker-username pact_workshop \ 1324 | --broker-password pact_workshop \ 1325 | --latest 1326 | 1327 | Computer says yes \o/ 1328 | 1329 | CONSUMER | C.VERSION | PROVIDER | P.VERSION | SUCCESS? 1330 | ----------------|-----------|----------------|-----------|--------- 1331 | FrontendWebsite | fe0b6a3 | ProductService | 1.0.0 | true 1332 | 1333 | All required verification results are published and successful 1334 | ``` 1335 | 1336 | That's it - you're now a Pact pro. Go build 🔨 1337 | 1338 | If you have extra time, why not try out Pact Webhooks 1339 | 1340 | *Move on to [step 12](https://github.com/pact-foundation/pact-workshop-js/tree/step12#step-12---using-webhooks)* 1341 | ## Step 12 - Using Webhooks 1342 | 1343 | _NOTE: Move to step 12:_ 1344 | 1345 | _`git checkout step12`_ 1346 | 1347 | _`npm install`_ 1348 | 1349 |
1350 | 1351 | **honours course** 1352 | 1353 | When a consumer contract is published, we want to trigger a provider build, in order to verify the contract. 1354 | 1355 | We can simulate this locally and explore the techniques involved. 1356 | 1357 | Update your docker-compose file to support webhooks running from your local machine 1358 | 1359 | 1. in `docker-compose.yaml` 1360 | 1361 | ```yml 1362 | PACT_BROKER_WEBHOOK_SCHEME_WHITELIST: http 1363 | PACT_BROKER_WEBHOOK_HOST_WHITELIST: host.docker.internal 1364 | ``` 1365 | 1366 | Recreate or Start the Pact Broker if its not already running 1367 | 1368 | 1. `docker compose up -d` 1369 | 1370 | Start the fake broker webhook service 1371 | 1372 | 1. `npm install` - we need to install the dependencies for a fake broker-webhook service. 1373 | 1. `npm run start --prefix broker-webhook` 1374 | 1375 | ```console 1376 | 1377 | > broker-webhook@1.0.0 start 1378 | > node server.js 1379 | 1380 | ## CI Simulator ## Broker webhook is listening on port 9090... 1381 | ``` 1382 | 1383 | Publish a webhook to our Pact broker 1384 | 1385 | 1. `npm run create-webhook --prefix broker-webhook` 1386 | 1387 | ```sh 1388 | curl http://host.docker.internal:8000/webhooks \ 1389 | -X POST --user pact_workshop:pact_workshop \ 1390 | -H "Content-Type: application/json" -d @broker-create-body.json -v 1391 | ``` 1392 | 1393 | This will send the following payload. 1394 | 1395 | ```json 1396 | { 1397 | "events": [ 1398 | { 1399 | "name": "contract_content_changed" 1400 | } 1401 | ], 1402 | "request": { 1403 | "method": "POST", 1404 | "url": "http://host.docker.internal:9090", 1405 | "headers": { 1406 | "Content-Type": "application/json" 1407 | }, 1408 | "body": { 1409 | "state": "${pactbroker.githubVerificationStatus}", 1410 | "description": "Pact Verification Tests ${pactbroker.providerVersionTags}", 1411 | "context": "${pactbroker.providerName}", 1412 | "target_url": "${pactbroker.verificationResultUrl}" 1413 | } 1414 | } 1415 | } 1416 | ``` 1417 | 1418 | ```console 1419 | > broker-webhook@1.0.0 create-webhook 1420 | > ./create_webhook.sh 1421 | 1422 | Note: Unnecessary use of -X or --request, POST is already inferred. 1423 | * Trying 127.0.0.1:8000... 1424 | * Connected to localhost (127.0.0.1) port 8000 (#0) 1425 | * Server auth using Basic with user 'pact_workshop' 1426 | > POST /webhooks HTTP/1.1 1427 | > Host: localhost:8000 1428 | > Authorization: Basic cGFjdF93b3Jrc2hvcDpwYWN0X3dvcmtzaG9w 1429 | > User-Agent: curl/8.1.2 1430 | > Accept: */* 1431 | > Content-Type: application/json 1432 | > Content-Length: 511 1433 | > 1434 | < HTTP/1.1 201 Created 1435 | < Vary: Accept 1436 | < Content-Type: application/hal+json;charset=utf-8 1437 | < Location: http://localhost:8000/webhooks/QxdSU5uCDllJTLDS_iLbNg 1438 | < Date: Fri, 29 Sep 2023 14:28:43 GMT 1439 | < Server: Webmachine-Ruby/2.0.0 Rack/1.3 1440 | < X-Pact-Broker-Version: 2.107.1 1441 | < X-Content-Type-Options: nosniff 1442 | < Content-Length: 926 1443 | < 1444 | * Connection #0 to host localhost left intact 1445 | {"uuid":"QxdSU5uCDllJTLDS_iLbNg","description":"POST host.docker.internal","enabled":true,"request":{"method":"POST","url":"http://host.docker.internal:9090","headers":{"Content-Type":"application/json"},"body":{"state":"${pactbroker.githubVerificationStatus}","description":"Pact Verification Tests ${pactbroker.providerVersionTags}","context":"${pactbroker.providerName}","target_url":"${pactbroker.verificationResultUrl}"}},"events":[{"name":"contract_content_changed"}],"createdAt":"2023-09-29T14:28:43+00:00","_links":{"self":{"title":"POST host.docker.internal","href":"http://localhost:8000/webhooks/QxdSU5uCDllJTLDS_iLbNg"},"pb:execute":{"title":"Test the execution of the webhook with the latest matching pact or verification by sending a POST request to this URL","href":"http://localhost:8000/webhooks/QxdSU5uCDllJTLDS_iLbNg/execute"},"pb:webhooks":{"title":"All webhooks","href":"http://localhost:8000/webhooks"}}}% 1446 | ``` 1447 | 1448 | Run the consumer pact tests 1449 | 1450 | 1. `npm run test:pact --prefix consumer` 1451 | 1452 | Publish the consumer pact tests 1453 | 1454 | 1. `npm run pact:publish --prefix consumer` 1455 | 1456 | ```console 1457 | > consumer@0.1.0 pact:publish 1458 | > pact-broker publish ./pacts --consumer-app-version="1.0.1" --auto-detect-version-properties --broker-base-url=http://127.0.0.1:8000 --broker-username pact_workshop --broker-password pact_workshop 1459 | 1460 | Created FrontendWebsite version 1.0.1 with branch all_steps 1461 | Pact successfully published for FrontendWebsite version 1.0.1 and provider ProductService. 1462 | View the published pact at http://127.0.0.1:8000/pacts/provider/ProductService/consumer/FrontendWebsite/version/1.0.1 1463 | Events detected: contract_published, contract_content_changed (first time untagged pact published) 1464 | Webhook QxdSU5uCDllJTLDS_iLbNg triggered for event contract_content_changed. 1465 | View logs at http://127.0.0.1:8000/triggered-webhooks/f8299b7a-53c4-4f5f-b4a8-7f87dbee1bdf/logs 1466 | Next steps: 1467 | * Add Pact verification tests to the ProductService build. See https://docs.pact.io/go/provider_verification 1468 | ``` 1469 | 1470 | This will trigger the provider tests. 1471 | 1472 | ```console 1473 | ## CI Simulator ## Broker webhook is listening on port 9090... 1474 | Got webhook {"state":"pending","description":"Pact Verification Tests ","context":"ProductService","target_url":""} 1475 | Triggering provider tests... 1476 | provider-verification: 1477 | > product-service@1.0.0 test:pact 1478 | > jest --testTimeout 30000 --testMatch "**/*.pact.test.js" 1479 | ``` 1480 | 1481 | Try publishing the contract again. You'll note the broker webhook does not trigger a 2nd time as the content has not changed. 1482 | 1483 | ```console 1484 | > consumer@0.1.0 pact:publish 1485 | > pact-broker publish ./pacts --consumer-app-version="1.0.1" --auto-detect-version-properties --broker-base-url=http://127.0.0.1:8000 --broker-username pact_workshop --broker-password pact_workshop 1486 | 1487 | Updated FrontendWebsite version 1.0.1 with branch step12 1488 | Pact successfully republished for FrontendWebsite version 1.0.1 and provider ProductService with no content changes. 1489 | View the published pact at http://127.0.0.1:8000/pacts/provider/ProductService/consumer/FrontendWebsite/version/1.0.1 1490 | Events detected: contract_published 1491 | No enabled webhooks found for the detected events 1492 | 1493 | ``` 1494 | 1495 | Try updating the version of the contract, in `consumer/package.json` 1496 | 1497 | ```json 1498 | "pact:publish": "pact-broker publish ./pacts --consumer-app-version=\"1.0.2\" --auto-detect-version-properties --broker-base-url=http://127.0.0.1:8000 --broker-username pact_workshop --broker-password pact_workshop", 1499 | ``` 1500 | 1501 | You'll again note, that as the contract version has changed, but the contents have not changed, since the last verification, the Pact Broker is aware of this, pre-verifying the Pact without needing to trigger the provider build. 1502 | 1503 | ``` 1504 | > consumer@0.1.0 pact:publish 1505 | > pact-broker publish ./pacts --consumer-app-version="1.0.2" --auto-detect-version-properties --broker-base-url=http://127.0.0.1:8000 --broker-username pact_workshop --broker-password pact_workshop 1506 | 1507 | Created FrontendWebsite version 1.0.2 with branch step12 1508 | Pact successfully published for FrontendWebsite version 1.0.2 and provider ProductService. 1509 | View the published pact at http://127.0.0.1:8000/pacts/provider/ProductService/consumer/FrontendWebsite/version/1.0.2 1510 | Events detected: contract_published (pact content is the same as previous versions with tags and no new tags were applied) 1511 | No enabled webhooks found for the detected events 1512 | ``` 1513 | 1514 | If you update the Pact contracts and attempt to republish under an existing version number, you will be stopped by the Pact Broker. 1515 | 1516 | This ensures contracts remain consistent once published. 1517 | 1518 | ```console 1519 | > consumer@0.1.0 pact:publish 1520 | > pact-broker publish ./pacts --consumer-app-version="1.0.2" --auto-detect-version-properties --broker-base-url=http://127.0.0.1:8000 --broker-username pact_workshop --broker-password pact_workshop 1521 | 1522 | Cannot change the content of the pact for ProductService version 1.0.2 and provider ProductService, as race conditions will cause unreliable results for can-i-deploy. Each pact must be published with a unique consumer version number. Some Pact libraries generate random data when a concrete value for a type matcher is not specified, and this can cause the contract to mutate - ensure you have given example values for all type matchers. For more information see https://docs.pact.io/go/versioning 1523 | ... , 1524 | { 1525 | "request": { 1526 | - "path": "/product/10" 1527 | + "path": "/product/11" 1528 | } 1529 | }, 1530 | ... , 1531 | ``` 1532 | 1533 | *Optional - Move on to [step 13](https://github.com/pact-foundation/pact-workshop-js/tree/step13#step-13---using-a-pactflow-broker)* for integrating with a PactFlow Broker 1534 | 1535 | ## Step 13 - Using a PactFlow Broker 1536 | 1537 | _NOTE: Move to step 13:_ 1538 | 1539 | _`git checkout step13`_ 1540 | 1541 | _`npm install`_ 1542 | 1543 |
1544 | 1545 | In step 11 we've been publishing our pacts from the consumer and provider projects to our locally hosted open source Pact broker. 1546 | 1547 | We can use a managed [Pact Broker](https://pactflow.io) from PactFlow to do this instead. 1548 | 1549 | Using a hosted pact broker with pactflow, will allow you to concentrate on testing your application without having to worry about managing infrastructure, along with a number of other useful [features](https://pactflow.io/features). 1550 | 1551 | ### Creating a pactflow account 1552 | 1553 | Create a new [PactFlow](https://pactflow.io/pricing) account and signup to the free Starter Plan. You will be emailed a set of credentials to access your account, these credentials are only for accessing the UI. 1554 | 1555 | Grab your [API Token](https://docs.pactflow.io/#configuring-your-api-token)(Click on settings -> API Tokens -> Read/write token -> COPY ENV VARS) and set the environment variables in your terminal as follows: 1556 | 1557 | 1558 | ```sh 1559 | export PACT_BROKER_BASE_URL=https://.pactflow.io 1560 | export PACT_BROKER_TOKEN=exampleToken 1561 | ``` 1562 | 1563 | ### Update your scripts to use the pact broker token based authentication method 1564 | 1565 | First, in the consumer project we need to tell Pact about our broker. 1566 | 1567 | In `consumer/publish.pact.js`: 1568 | 1569 | ```javascript 1570 | const pact = require('@pact-foundation/pact-node'); 1571 | 1572 | if (!process.env.CI && !process.env.PUBLISH_PACT) { 1573 | console.log("skipping Pact publish..."); 1574 | process.exit(0) 1575 | } 1576 | 1577 | const pactBrokerUrl = process.env.PACT_BROKER_BASE_URL || 'https://.pactflow.io'; 1578 | const pactBrokerToken = process.env.PACT_BROKER_TOKEN || 'pact_workshop'; 1579 | 1580 | const gitHash = require('child_process') 1581 | .execSync('git rev-parse --short HEAD') 1582 | .toString().trim(); 1583 | 1584 | const opts = { 1585 | pactFilesOrDirs: ['./pacts/'], 1586 | pactBroker: pactBrokerUrl, 1587 | pactBrokerToken: pactBrokerToken, 1588 | tags: ['prod', 'test'], 1589 | consumerVersion: gitHash 1590 | }; 1591 | 1592 | pact 1593 | .publishPacts(opts) 1594 | .then(() => { 1595 | console.log('Pact contract publishing complete!'); 1596 | console.log(''); 1597 | console.log(`Head over to ${pactBrokerUrl}`); 1598 | console.log('to see your published contracts.') 1599 | }) 1600 | .catch(e => { 1601 | console.log('Pact contract publishing failed: ', e) 1602 | }); 1603 | ``` 1604 | 1605 | Now run 1606 | 1607 | ```console 1608 | ❯ npm run test:pact --prefix consumer 1609 | 1610 | > consumer@0.1.0 test:pact /Users/you54f/dev/saf/dev/pact-workshop-clone/consumer 1611 | > react-scripts test --testTimeout 30000 pact.spec.js 1612 | 1613 | PASS src/api.pact.spec.js 1614 | API Pact test 1615 | getting all products 1616 | ✓ products exists (19ms) 1617 | ✓ no products exists (10ms) 1618 | ✓ no auth token (10ms) 1619 | getting one product 1620 | ✓ ID 10 exists (10ms) 1621 | ✓ product does not exist (8ms) 1622 | ✓ no auth token (12ms) 1623 | ``` 1624 | 1625 | Then publish your pacts: 1626 | 1627 | ``` 1628 | ❯ npm run pact:publish --prefix consumer 1629 | 1630 | > pact-broker publish ./pacts --consumer-app-version="1.0.0" --auto-detect-version-properties 1631 | 1632 | Updated FrontendWebsite version 71c1b7-step12+71c1b7.SNAPSHOT.SB-AS-G7GM9F7 with branch step12 1633 | Pact successfully published for FrontendWebsite version 71c1b7-step12+71c1b7.SNAPSHOT.SB-AS-G7GM9F7 and provider ProductService. 1634 | View the published pact at https://testdemo.pactflow.io/pacts/provider/ProductService/consumer/FrontendWebsite/version/71c1b7-step12%2B71c1b7.SNAPSHOT.SB-AS-G7GM9F7 1635 | Events detected: contract_published, contract_requiring_verification_published, contract_content_changed (first time untagged pact published) 1636 | Webhook "Automatically trigger pact verification on contract change." triggered for event contract_requiring_verification_published. 1637 | View logs at https://testdemo.pactflow.io/triggered-webhooks/fa8d571e-8b61-41f8-9955-79a6fa9481fd/logs 1638 | ``` 1639 | 1640 | Have a browse around your pactflow broker and see your newly published contract 1641 | 1642 | ### Verify contracts on Provider 1643 | 1644 | All we need to do for the provider is update where it finds its pacts, from local broker, to one from a hosted pactflow broker 1645 | 1646 | In `provider/product/product.pact.test.js`: 1647 | 1648 | ```javascript 1649 | //replace 1650 | pactBrokerUrl: process.env.PACT_BROKER_BASE_URL || "http://127.0.0.1:8000", 1651 | pactBrokerUsername: process.env.PACT_BROKER_USERNAME || "pact_workshop", 1652 | pactBrokerPassword: process.env.PACT_BROKER_PASSWORD || "pact_workshop", 1653 | 1654 | // with 1655 | pactBrokerUrl :process.env.PACT_BROKER_BASE_URL || 'https://.pactflow.io', 1656 | pactBrokerToken: process.env.PACT_BROKER_TOKEN || 'pact_workshop', 1657 | ``` 1658 | 1659 | 1660 | Let's run the provider verification one last time after this change: 1661 | 1662 | ```console 1663 | ❯ npm run test:pact --prefix provider 1664 | 1665 | > product-service@1.0.0 test:pact /Users/you54f/dev/saf/dev/pact-workshop-clone/provider 1666 | > jest --testTimeout 30000 --testMatch "**/*.pact.test.js" 1667 | 1668 | INFO: pact@9.11.1/84537 on safmac.local: Verifying provider 1669 | INFO: pact-node@10.10.1/84537 on safmac.local: Verifying Pacts. 1670 | INFO: pact-node@10.10.1/84537 on safmac.local: Verifying Pact Files 1671 | PASS product/product.pact.test.js (6.786s) 1672 | Pact Verification 1673 | ✓ validates the expectations of ProductService (6006ms) 1674 | INFO: Verification results published to https://you54f.pactflow.io/pacts/provider/ProductService/consumer/FrontendWebsite/pact-version/c4b62aae734255d00eba62ced76594343a148e29/verification-results/256 1675 | 1676 | ``` 1677 | 1678 | ### Can I deploy? 1679 | 1680 | As per step 11, we can use the `can-i-deploy` command to gate releases. 1681 | 1682 | You can run the `pact-broker can-i-deploy` checks as follows: 1683 | 1684 | ```console 1685 | ❯ cd consumer 1686 | ❯ npx pact-broker can-i-deploy \ 1687 | --pacticipant FrontendWebsite \ 1688 | --latest 1689 | 1690 | Computer says yes \o/ 1691 | 1692 | CONSUMER | C.VERSION | PROVIDER | P.VERSION | SUCCESS? 1693 | ----------------|-----------|----------------|-----------|--------- 1694 | FrontendWebsite | fe0b6a3 | ProductService | 1.0.0 | true 1695 | 1696 | All required verification results are published and successful 1697 | 1698 | ---------------------------- 1699 | 1700 | ❯ cd provider 1701 | ❯ npx pact-broker can-i-deploy \ 1702 | --pacticipant ProductService \ 1703 | --latest 1704 | 1705 | Computer says yes \o/ 1706 | 1707 | CONSUMER | C.VERSION | PROVIDER | P.VERSION | SUCCESS? 1708 | ----------------|-----------|----------------|-----------|--------- 1709 | FrontendWebsite | fe0b6a3 | ProductService | 1.0.0 | true 1710 | 1711 | All required verification results are published and successful 1712 | ``` 1713 | 1714 | _NOTE_: Because we have exported the `PACT_*` environment variables, we can omit the necessary flags on the command. 1715 | 1716 | That's it - you're now a Pact pro. Go build 🔨 1717 | -------------------------------------------------------------------------------- /consumer/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_BASE_URL=http://127.0.0.1:8080 -------------------------------------------------------------------------------- /consumer/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "react": { 4 | "version": "16.12.0" 5 | } 6 | }, 7 | "env": { 8 | "browser": true, 9 | "es6": true, 10 | "jest": true 11 | }, 12 | "extends": [ 13 | "eslint:recommended", 14 | "plugin:react/recommended" 15 | ], 16 | "globals": { 17 | "Atomics": "readonly", 18 | "SharedArrayBuffer": "readonly", 19 | "process": true 20 | }, 21 | "parserOptions": { 22 | "ecmaFeatures": { 23 | "jsx": true 24 | }, 25 | "ecmaVersion": 2018, 26 | "sourceType": "module" 27 | }, 28 | "plugins": [ 29 | "react" 30 | ], 31 | "rules": { 32 | } 33 | } -------------------------------------------------------------------------------- /consumer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "consumer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^1.7.7", 7 | "react": "18.3.1", 8 | "react-dom": "18.3.1", 9 | "react-router": "6.27.0", 10 | "react-router-dom": "6.27.0", 11 | "react-scripts": "5.0.1", 12 | "spectre.css": "0.5.9", 13 | "web-vitals": "4.2.3" 14 | }, 15 | "devDependencies": { 16 | "@babel/plugin-proposal-private-property-in-object": "7.21.11", 17 | "@pact-foundation/pact": "13.1.4", 18 | "@pact-foundation/pact-cli": "16.0.4", 19 | "@testing-library/react": "16.0.1", 20 | "@testing-library/user-event": "14.5.2", 21 | "babel-jest": "^29.7.0", 22 | "cross-env": "7.0.3", 23 | "nock": "13.5.5", 24 | "rimraf": "6.0.1" 25 | }, 26 | "scripts": { 27 | "start": "react-scripts start", 28 | "build": "react-scripts build", 29 | "test": "cross-env CI=true react-scripts test", 30 | "eject": "react-scripts eject", 31 | "pretest:pact": "rimraf pacts/*.json", 32 | "test:pact": "cross-env CI=true react-scripts test --testTimeout=30000 pact.spec.js", 33 | "test:unit": "cross-env CI=true react-scripts test api.spec.js", 34 | "pact:publish": "pact-broker publish ./pacts --consumer-app-version=\"1.0.0\" --auto-detect-version-properties --broker-base-url=http://127.0.0.1:8000 --broker-username pact_workshop --broker-password pact_workshop" 35 | }, 36 | "eslintConfig": { 37 | "extends": "react-app" 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | }, 51 | "jest": { 52 | "transformIgnorePatterns": [ 53 | "node_modules/(?!(axios)/)" 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /consumer/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Pact Workshop JS 12 | 13 | 14 | 15 |
16 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /consumer/public/sad_panda.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pact-foundation/pact-workshop-js/22d6e16619a5b3db1dc4d0dacb7b1333493aed97/consumer/public/sad_panda.gif -------------------------------------------------------------------------------- /consumer/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /consumer/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Link} from 'react-router-dom'; 3 | import 'spectre.css/dist/spectre.min.css'; 4 | import 'spectre.css/dist/spectre-icons.min.css'; 5 | import 'spectre.css/dist/spectre-exp.min.css'; 6 | import Heading from "./Heading"; 7 | import Layout from "./Layout"; 8 | import API from "./api"; 9 | import PropTypes from 'prop-types'; 10 | 11 | const productPropTypes = { 12 | product: PropTypes.shape({ 13 | id: PropTypes.string.isRequired, 14 | name: PropTypes.string.isRequired, 15 | type: PropTypes.string.isRequired, 16 | }).isRequired 17 | }; 18 | 19 | function ProductTableRow(props) { 20 | return ( 21 | 22 | {props.product.name} 23 | {props.product.type} 24 | 25 | See more! 31 | 32 | 33 | ); 34 | } 35 | ProductTableRow.propTypes = productPropTypes; 36 | 37 | function ProductTable(props) { 38 | const products = props.products.map(p => ( 39 | 40 | )); 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 49 | 50 | 51 | {products} 52 | 53 |
NameType 48 |
54 | ); 55 | } 56 | 57 | ProductTable.propTypes = { 58 | products: PropTypes.arrayOf(productPropTypes.product) 59 | }; 60 | 61 | class App extends React.Component { 62 | constructor(props) { 63 | super(props); 64 | 65 | this.state = { 66 | loading: true, 67 | searchText: '', 68 | products: [], 69 | visibleProducts: [] 70 | }; 71 | this.onSearchTextChange = this.onSearchTextChange.bind(this); 72 | } 73 | 74 | componentDidMount() { 75 | API.getAllProducts() 76 | .then(r => { 77 | this.setState({ 78 | loading: false, 79 | products: r 80 | }); 81 | this.determineVisibleProducts(); 82 | }) 83 | .catch(() => { 84 | history.pushState({error:"products could not be found"},"productsError") 85 | this.setState({error: true}) 86 | }); 87 | } 88 | 89 | determineVisibleProducts() { 90 | const findProducts = (search) => { 91 | search = search.toLowerCase(); 92 | return this.state.products.filter(p => 93 | p.id.toLowerCase().includes(search) 94 | || p.name.toLowerCase().includes(search) 95 | || p.type.toLowerCase().includes(search) 96 | ) 97 | }; 98 | this.setState((s) => { 99 | return { 100 | visibleProducts: s.searchText ? findProducts(s.searchText) : s.products 101 | } 102 | }); 103 | } 104 | 105 | onSearchTextChange(e) { 106 | this.setState({ 107 | searchText: e.target.value 108 | }); 109 | this.determineVisibleProducts() 110 | } 111 | 112 | render() { 113 | if (this.state.error) { 114 | throw Error("unable to fetch product data") 115 | } 116 | 117 | return ( 118 | 119 | 120 |
121 | 122 | 124 |
125 | { 126 | this.state.loading ? 127 |
: 128 | 129 | } 130 | 131 | ); 132 | } 133 | } 134 | 135 | App.propTypes = { 136 | history: PropTypes.shape({ 137 | push: PropTypes.func.isRequired 138 | } 139 | ).isRequired 140 | }; 141 | 142 | export default App; 143 | -------------------------------------------------------------------------------- /consumer/src/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Layout from "./Layout"; 4 | import Heading from "./Heading"; 5 | 6 | export default class ErrorBoundary extends React.Component { 7 | state = { has_error: false }; 8 | 9 | componentDidCatch() { 10 | this.setState({ has_error: true }); 11 | } 12 | 13 | render() { 14 | if (this.state.has_error) { 15 | return ( 16 | 17 | 18 |
19 | sad_panda 27 |
33 |             
34 |                 {history && history.state && history.state.error ? history.state.error : "Oh noes"}
35 |             
36 |             
37 |
38 |
39 | ); 40 | } 41 | return this.props.children; 42 | } 43 | } 44 | 45 | ErrorBoundary.propTypes = { 46 | children: PropTypes.object.isRequired 47 | }; 48 | -------------------------------------------------------------------------------- /consumer/src/Heading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | function Heading(props) { 5 | return ( 6 |
7 |

{props.text}

11 |
12 |
13 | ); 14 | } 15 | 16 | Heading.propTypes = { 17 | href: PropTypes.string.isRequired, 18 | text: PropTypes.string.isRequired 19 | }; 20 | 21 | export default Heading; -------------------------------------------------------------------------------- /consumer/src/Layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | function Layout(props) { 5 | return ( 6 |
7 |
8 |
9 | {props.children} 10 |
11 |
12 |
13 | ); 14 | } 15 | 16 | Layout.propTypes = { 17 | children: PropTypes.array.isRequired 18 | }; 19 | 20 | export default Layout; -------------------------------------------------------------------------------- /consumer/src/ProductPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import 'spectre.css/dist/spectre.min.css'; 3 | import 'spectre.css/dist/spectre-icons.min.css'; 4 | import 'spectre.css/dist/spectre-exp.min.css'; 5 | import Layout from "./Layout"; 6 | import Heading from "./Heading"; 7 | import API from "./api"; 8 | 9 | class ProductPage extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | 13 | const bits = window.location.pathname.split("/") 14 | 15 | this.state = { 16 | loading: true, 17 | product: { 18 | id: bits[bits.length-1] 19 | } 20 | }; 21 | } 22 | 23 | componentDidMount() { 24 | API.getProduct(this.state.product.id).then(r => { 25 | this.setState({ 26 | loading: false, 27 | product: r 28 | }); 29 | }).catch(() => { 30 | history.pushState({error:`product with ${this.state.product.id} could not be found`},"productsError") 31 | this.setState({error: true}) 32 | }) 33 | } 34 | 35 | render() { 36 | if (this.state.error) { 37 | throw Error("unable to fetch product data") 38 | } 39 | const productInfo = ( 40 |
41 |

ID: {this.state.product.id}

42 |

Name: {this.state.product.name}

43 |

Type: {this.state.product.type}

44 |
45 | ); 46 | 47 | return ( 48 | 49 | 50 | {this.state.loading ?
: productInfo} 56 | 57 | ); 58 | } 59 | } 60 | 61 | ProductPage.propTypes = { 62 | // match: PropTypes.array.isRequired, 63 | // history: PropTypes.shape({ 64 | // push: PropTypes.func.isRequired 65 | // }).isRequired 66 | }; 67 | 68 | export default ProductPage; -------------------------------------------------------------------------------- /consumer/src/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | // axios.defaults.adapter = "http" 3 | export class API { 4 | 5 | constructor(url) { 6 | if (url === undefined || url === "") { 7 | url = process.env.REACT_APP_API_BASE_URL; 8 | } 9 | if (url.endsWith("/")) { 10 | url = url.substr(0, url.length - 1) 11 | } 12 | this.url = url 13 | } 14 | 15 | withPath(path) { 16 | if (!path.startsWith("/")) { 17 | path = "/" + path 18 | } 19 | return `${this.url}${path}` 20 | } 21 | 22 | generateAuthToken() { 23 | return "Bearer " + new Date().toISOString() 24 | } 25 | 26 | async getAllProducts() { 27 | return axios.get(this.withPath("/products"), { 28 | headers: { 29 | "Authorization": this.generateAuthToken() 30 | } 31 | }) 32 | .then(r => r.data); 33 | } 34 | 35 | async getProduct(id) { 36 | return axios.get(this.withPath("/product/" + id), { 37 | headers: { 38 | "Authorization": this.generateAuthToken() 39 | } 40 | }) 41 | .then(r => r.data); 42 | } 43 | } 44 | 45 | export default new API(process.env.REACT_APP_API_BASE_URL); -------------------------------------------------------------------------------- /consumer/src/api.pact.spec.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { 3 | PactV3, 4 | MatchersV3, 5 | SpecificationVersion, 6 | } from "@pact-foundation/pact"; 7 | import { API } from "./api"; 8 | const { eachLike, like } = MatchersV3; 9 | 10 | const provider = new PactV3({ 11 | consumer: "FrontendWebsite", 12 | provider: "ProductService", 13 | log: path.resolve(process.cwd(), "logs", "pact.log"), 14 | logLevel: "warn", 15 | dir: path.resolve(process.cwd(), "pacts"), 16 | spec: SpecificationVersion.SPECIFICATION_VERSION_V2, 17 | host: "127.0.0.1" 18 | }); 19 | 20 | describe("API Pact test", () => { 21 | describe("getting all products", () => { 22 | test("products exists", async () => { 23 | // set up Pact interactions 24 | await provider.addInteraction({ 25 | states: [{ description: "products exist" }], 26 | uponReceiving: "get all products", 27 | withRequest: { 28 | method: "GET", 29 | path: "/products", 30 | headers: { 31 | Authorization: like("Bearer 2019-01-14T11:34:18.045Z"), 32 | }, 33 | }, 34 | willRespondWith: { 35 | status: 200, 36 | headers: { 37 | "Content-Type": "application/json; charset=utf-8", 38 | }, 39 | body: eachLike({ 40 | id: "09", 41 | type: "CREDIT_CARD", 42 | name: "Gem Visa", 43 | }), 44 | }, 45 | }); 46 | 47 | await provider.executeTest(async (mockService) => { 48 | const api = new API(mockService.url); 49 | 50 | // make request to Pact mock server 51 | const product = await api.getAllProducts(); 52 | 53 | expect(product).toStrictEqual([ 54 | { id: "09", name: "Gem Visa", type: "CREDIT_CARD" }, 55 | ]); 56 | }); 57 | }); 58 | 59 | test("no products exists", async () => { 60 | // set up Pact interactions 61 | await provider.addInteraction({ 62 | states: [{ description: "no products exist" }], 63 | uponReceiving: "get all products", 64 | withRequest: { 65 | method: "GET", 66 | path: "/products", 67 | headers: { 68 | Authorization: like("Bearer 2019-01-14T11:34:18.045Z"), 69 | }, 70 | }, 71 | willRespondWith: { 72 | status: 200, 73 | headers: { 74 | "Content-Type": "application/json; charset=utf-8", 75 | }, 76 | body: [], 77 | }, 78 | }); 79 | 80 | await provider.executeTest(async (mockService) => { 81 | const api = new API(mockService.url); 82 | 83 | // make request to Pact mock server 84 | const product = await api.getAllProducts(); 85 | 86 | expect(product).toStrictEqual([]); 87 | }); 88 | }); 89 | 90 | test("no auth token", async () => { 91 | // set up Pact interactions 92 | await provider.addInteraction({ 93 | states: [{ description: "products exist" }], 94 | uponReceiving: "get all products", 95 | withRequest: { 96 | method: "GET", 97 | path: "/products", 98 | }, 99 | willRespondWith: { 100 | status: 401, 101 | }, 102 | }); 103 | 104 | await provider.executeTest(async (mockService) => { 105 | const api = new API(mockService.url); 106 | 107 | // make request to Pact mock server 108 | await expect(api.getAllProducts()).rejects.toThrow( 109 | "Request failed with status code 401" 110 | ); 111 | }); 112 | }); 113 | }); 114 | 115 | describe("getting one product", () => { 116 | test("ID 10 exists", async () => { 117 | // set up Pact interactions 118 | await provider.addInteraction({ 119 | states: [{ description: "product with ID 10 exists" }], 120 | uponReceiving: "get product with ID 10", 121 | withRequest: { 122 | method: "GET", 123 | path: "/product/10", 124 | headers: { 125 | Authorization: like("Bearer 2019-01-14T11:34:18.045Z"), 126 | }, 127 | }, 128 | willRespondWith: { 129 | status: 200, 130 | headers: { 131 | "Content-Type": "application/json; charset=utf-8", 132 | }, 133 | body: like({ 134 | id: "10", 135 | type: "CREDIT_CARD", 136 | name: "28 Degrees", 137 | }), 138 | }, 139 | }); 140 | 141 | await provider.executeTest(async (mockService) => { 142 | const api = new API(mockService.url); 143 | 144 | // make request to Pact mock server 145 | const product = await api.getProduct("10"); 146 | 147 | expect(product).toStrictEqual({ 148 | id: "10", 149 | type: "CREDIT_CARD", 150 | name: "28 Degrees", 151 | }); 152 | }); 153 | }); 154 | 155 | test("product does not exist", async () => { 156 | // set up Pact interactions 157 | await provider.addInteraction({ 158 | states: [{ description: "product with ID 11 does not exist" }], 159 | uponReceiving: "get product with ID 11", 160 | withRequest: { 161 | method: "GET", 162 | path: "/product/11", 163 | headers: { 164 | Authorization: like("Bearer 2019-01-14T11:34:18.045Z"), 165 | }, 166 | }, 167 | willRespondWith: { 168 | status: 404, 169 | }, 170 | }); 171 | 172 | await provider.executeTest(async (mockService) => { 173 | const api = new API(mockService.url); 174 | 175 | // make request to Pact mock server 176 | await expect(api.getProduct("11")).rejects.toThrow( 177 | "Request failed with status code 404" 178 | ); 179 | }); 180 | }); 181 | 182 | test("no auth token", async () => { 183 | // set up Pact interactions 184 | await provider.addInteraction({ 185 | states: [{ description: "product with ID 10 exists" }], 186 | uponReceiving: "get product by ID 10 with no auth token", 187 | withRequest: { 188 | method: "GET", 189 | path: "/product/10", 190 | }, 191 | willRespondWith: { 192 | status: 401, 193 | }, 194 | }); 195 | 196 | await provider.executeTest(async (mockService) => { 197 | const api = new API(mockService.url); 198 | 199 | // make request to Pact mock server 200 | await expect(api.getProduct("10")).rejects.toThrow( 201 | "Request failed with status code 401" 202 | ); 203 | }); 204 | }); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /consumer/src/api.spec.js: -------------------------------------------------------------------------------- 1 | import API from "./api"; 2 | import nock from "nock"; 3 | import axios from 'axios' 4 | 5 | axios.defaults.adapter = 'http' // https://github.com/nock/nock?tab=readme-ov-file#axios 6 | 7 | describe("API", () => { 8 | 9 | test("get all products", async () => { 10 | const products = [ 11 | { 12 | "id": "9", 13 | "type": "CREDIT_CARD", 14 | "name": "GEM Visa", 15 | "version": "v2" 16 | }, 17 | { 18 | "id": "10", 19 | "type": "CREDIT_CARD", 20 | "name": "28 Degrees", 21 | "version": "v1" 22 | } 23 | ]; 24 | nock(API.url) 25 | .get('/products') 26 | .reply(200, 27 | products, 28 | {'Access-Control-Allow-Origin': '*'}); 29 | const respProducts = await API.getAllProducts(); 30 | expect(respProducts).toEqual(products); 31 | }); 32 | 33 | test("get product ID 50", async () => { 34 | const product = { 35 | "id": "50", 36 | "type": "CREDIT_CARD", 37 | "name": "28 Degrees", 38 | "version": "v1" 39 | }; 40 | nock(API.url) 41 | .get('/product/50') 42 | .reply(200, product, {'Access-Control-Allow-Origin': '*'}); 43 | const respProduct = await API.getProduct("50"); 44 | expect(respProduct).toEqual(product); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /consumer/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | /* https://stackoverflow.com/questions/46589819/disable-error-overlay-in-development-mode */ 16 | iframe#webpack-dev-server-client-overlay { 17 | display: none; 18 | } 19 | -------------------------------------------------------------------------------- /consumer/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { BrowserRouter, Routes, Route } from "react-router-dom"; 4 | import "./index.css"; 5 | import App from "./App"; 6 | import ProductPage from "./ProductPage"; 7 | import ErrorBoundary from "./ErrorBoundary"; 8 | 9 | const routing = ( 10 | 11 |
12 | 13 | 14 | } /> 15 | 16 | } /> 17 | 18 | 19 | 20 |
21 |
22 | ); 23 | 24 | const root = ReactDOM.createRoot(document.getElementById("root")); 25 | root.render(routing); -------------------------------------------------------------------------------- /consumer/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /diagrams/workshop_step1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 15 |
17 |
19 | Consumer 20 |
21 |
22 |
23 | 24 | Consumer 25 | 26 |
27 |
28 | 29 | 30 | 31 | 33 |
35 |
37 | Provider 38 |
39 |
40 |
41 | 42 | Provider 43 | 44 |
45 |
46 | 47 | 48 | 49 | 51 |
53 |
55 | HTTP 56 |
57 |
58 |
59 | HTTP 60 | 61 |
62 |
63 |
64 |
-------------------------------------------------------------------------------- /diagrams/workshop_step10_broker.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 11 | 12 | 13 | 15 |
17 |
19 | For each pact file, for each interaction 20 |
21 |
22 |
23 | For 24 | each pact file, for each interaction 25 | 26 |
27 |
28 | 30 | 32 | 34 | 35 | 37 | 39 | 40 | 41 | 43 |
45 |
47 | Provider 48 |
49 |
50 |
51 | 52 | Provider 53 | 54 |
55 |
56 | 58 | 59 | 60 | 62 |
64 |
66 | Pact 67 |
68 |
69 |
70 | Pact 71 | 72 |
73 |
74 | 76 | 77 | 78 | 80 |
82 |
84 | Broker 85 |
86 |
87 |
88 | 89 | Broker 90 | 91 |
92 |
93 | 94 | 95 | 96 | 98 |
100 |
102 | Consumer 103 |
104 |
105 |
106 | 107 | Consumer 108 | 109 |
110 |
111 | 113 | 115 | 117 | 119 | 120 | 122 | 123 | 124 | 126 |
128 |
130 | Pact File 131 |
132 |
133 |
134 | Pact 135 | File 136 | 137 |
138 |
139 | 140 | 141 | 143 | 145 | 147 | 148 | 149 | 151 |
153 |
155 | Pacts 156 |
157 |
158 |
159 | [Not 160 | supported by viewer] 161 | 162 |
163 |
164 | 165 | 166 | 167 | 169 |
171 |
173 | ... 174 |
175 |
176 |
177 | ... 178 | 179 |
180 |
181 | 183 | 184 | 186 | 187 | 188 | 190 |
192 |
194 | Pact File 195 |
196 |
197 |
198 | Pact 199 | File 200 | 201 |
202 |
203 | 205 | 207 | 208 | 210 | 211 | 212 | 213 | 215 |
217 |
219 | Fetch pacts 220 |
221 |
222 |
223 | Fetch pacts 225 | 226 |
227 |
228 | 230 | 232 | 233 | 234 | 235 | 237 |
239 |
241 | Perform HTTP Request 242 |
243 |
244 |
245 | Perform HTTP Request 247 | 248 |
249 |
250 | 251 | 252 | 253 | 255 |
257 |
259 | Assert HTTP Response 260 |
261 |
262 |
263 | Assert HTTP Response 265 | 266 |
267 |
268 | 270 | 272 | 273 | 274 | 275 | 277 |
279 |
281 | { 
    ...
} 282 |
283 |
284 |
285 | [Not 286 | supported by viewer] 287 | 288 |
289 |
290 | 291 | 292 | 294 | 296 | 298 | 299 | 300 | 301 | 303 |
305 |
307 | Publish verification results 308 |
309 |
310 |
311 | Publish verification results 313 | 314 |
315 |
316 | 317 | 318 | 319 | 321 |
323 |
325 | 326 |
327 |
328 |
329 | [Not supported by viewer] 331 | 332 |
333 |
334 |
335 |
-------------------------------------------------------------------------------- /diagrams/workshop_step1_class-sequence-diagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
Consumer
Consumer
Provider
Provider
api.getProduct(10)
api.getProduct(10)
GET /products/10
GET /products/10

    "id": "10",
    "type": "CREDIT_CARD",
    "name": "28 Degrees",
    "version": "v1"
}
{...
Test
Test
-------------------------------------------------------------------------------- /diagrams/workshop_step1_failed_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pact-foundation/pact-workshop-js/22d6e16619a5b3db1dc4d0dacb7b1333493aed97/diagrams/workshop_step1_failed_page.png -------------------------------------------------------------------------------- /diagrams/workshop_step2_failed_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pact-foundation/pact-workshop-js/22d6e16619a5b3db1dc4d0dacb7b1333493aed97/diagrams/workshop_step2_failed_page.png -------------------------------------------------------------------------------- /diagrams/workshop_step2_unit_test.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
Consumer
Consumer
Provider
Provider
api.getProduct(10)
api.getProduct(10)
GET /products/10
GET /products/10

    "id": "10",
    "type": "CREDIT_CARD",
    "name": "28 Degrees",
    "version": "v1"
}
{...
Test
Test
-------------------------------------------------------------------------------- /diagrams/workshop_step3_pact.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
Consumer
Consumer
Provider
Provider
api.getProduct(10)
api.getProduct(10)
GET /products/10
GET /products/10

    "id": "10",
    "type": "CREDIT_CARD",
    "name": "28 Degrees",
    "version": "v1"
}
{...
Pact
Pact
Pact File
Pact File
-------------------------------------------------------------------------------- /diagrams/workshop_step4_pact.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 10 | 11 | 13 |
15 |
17 | Provider 18 |
19 |
20 |
21 | 22 | Provider 23 | 24 |
25 |
26 | 28 | 29 | 30 | 31 | 33 |
35 |
37 | Pact 38 |
39 |
40 |
41 | Pact 42 | 43 |
44 |
45 | 47 | 48 | 49 | 50 | 52 |
54 |
56 | Pact File 57 |
58 |
59 |
60 | Pact 61 | File 62 | 63 |
64 |
65 | 66 | 68 | 69 | 71 | 73 | 75 | 76 | 77 | 78 | 80 |
82 |
84 | GET /products/10 85 |
86 |
87 |
88 | GET /products/10 90 | 91 |
92 |
93 | 95 | 97 | 98 | 99 | 100 | 102 |
104 |
106 | HTTP 404 Not Found 107 |
108 |
109 |
110 | HTTP 404 Not Found 112 | 113 |
114 |
115 | 116 | 117 | 118 | 120 |
122 |
124 | X 125 |
126 |
127 |
128 | X 130 | 131 |
132 |
133 |
134 |
-------------------------------------------------------------------------------- /diagrams/workshop_step5_pact.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 10 | 11 | 13 |
15 |
17 | Provider 18 |
19 |
20 |
21 | 22 | Provider 23 | 24 |
25 |
26 | 28 | 30 | 31 | 32 | 34 |
36 |
38 | Pact 39 |
40 |
41 |
42 | Pact 43 | 44 |
45 |
46 | 48 | 50 | 51 | 52 | 54 |
56 |
58 | Pact File 59 |
60 |
61 |
62 | Pact 63 | File 64 | 65 |
66 |
67 | 69 | 71 | 72 | 74 | 76 | 78 | 79 | 80 | 81 | 83 |
85 |
87 | GET /product/10 88 |
89 |
90 |
91 | GET /product/10 93 | 94 |
95 |
96 | 97 | 99 | 100 | 101 | 102 | 104 |
106 |
108 | HTTP 200 OK 109 |
110 |
111 |
112 | HTTP 200 OK 114 | 115 |
116 |
117 | 118 | 119 | 120 | 122 |
124 |
126 | 127 |
128 |
129 |
130 | [Not supported by viewer] 132 | 133 |
134 |
135 | 136 | 137 | 138 | 140 |
142 |
144 | { 
    "id": "10",
    "type": "CREDIT_CARD",
    "name": "28 Degrees",
    145 | "version": "v1"
} 146 |
147 |
148 |
149 | [Not 150 | supported by viewer] 151 | 152 |
153 |
154 | 156 |
157 |
-------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres 4 | healthcheck: 5 | test: psql postgres --command "select 1" -U postgres 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: password 11 | POSTGRES_DB: postgres 12 | 13 | pact-broker: 14 | image: pactfoundation/pact-broker:latest 15 | links: 16 | - postgres 17 | ports: 18 | - 8000:9292 19 | restart: always 20 | environment: 21 | PACT_BROKER_BASIC_AUTH_USERNAME: pact_workshop 22 | PACT_BROKER_BASIC_AUTH_PASSWORD: pact_workshop 23 | PACT_BROKER_DATABASE_USERNAME: postgres 24 | PACT_BROKER_DATABASE_PASSWORD: password 25 | PACT_BROKER_DATABASE_HOST: postgres 26 | PACT_BROKER_DATABASE_NAME: postgres 27 | PACT_BROKER_DATABASE_CONNECT_MAX_RETRIES: "10" 28 | PACT_BROKER_PUBLIC_HEARTBEAT: "true" -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pact-workshop-js", 3 | "version": "0.1.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "pact-workshop-js", 9 | "version": "0.1.0", 10 | "hasInstallScript": true, 11 | "dependencies": { 12 | "concurrently": "9.0.1" 13 | } 14 | }, 15 | "node_modules/ansi-regex": { 16 | "version": "5.0.1", 17 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 18 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 19 | "engines": { 20 | "node": ">=8" 21 | } 22 | }, 23 | "node_modules/ansi-styles": { 24 | "version": "4.3.0", 25 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 26 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 27 | "dependencies": { 28 | "color-convert": "^2.0.1" 29 | }, 30 | "engines": { 31 | "node": ">=8" 32 | }, 33 | "funding": { 34 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 35 | } 36 | }, 37 | "node_modules/chalk": { 38 | "version": "4.1.2", 39 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 40 | "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 41 | "dependencies": { 42 | "ansi-styles": "^4.1.0", 43 | "supports-color": "^7.1.0" 44 | }, 45 | "engines": { 46 | "node": ">=10" 47 | }, 48 | "funding": { 49 | "url": "https://github.com/chalk/chalk?sponsor=1" 50 | } 51 | }, 52 | "node_modules/chalk/node_modules/supports-color": { 53 | "version": "7.2.0", 54 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 55 | "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 56 | "dependencies": { 57 | "has-flag": "^4.0.0" 58 | }, 59 | "engines": { 60 | "node": ">=8" 61 | } 62 | }, 63 | "node_modules/cliui": { 64 | "version": "8.0.1", 65 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", 66 | "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", 67 | "dependencies": { 68 | "string-width": "^4.2.0", 69 | "strip-ansi": "^6.0.1", 70 | "wrap-ansi": "^7.0.0" 71 | }, 72 | "engines": { 73 | "node": ">=12" 74 | } 75 | }, 76 | "node_modules/color-convert": { 77 | "version": "2.0.1", 78 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 79 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 80 | "dependencies": { 81 | "color-name": "~1.1.4" 82 | }, 83 | "engines": { 84 | "node": ">=7.0.0" 85 | } 86 | }, 87 | "node_modules/color-name": { 88 | "version": "1.1.4", 89 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 90 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 91 | }, 92 | "node_modules/concurrently": { 93 | "version": "9.0.1", 94 | "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.0.1.tgz", 95 | "integrity": "sha512-wYKvCd/f54sTXJMSfV6Ln/B8UrfLBKOYa+lzc6CHay3Qek+LorVSBdMVfyewFhRbH0Rbabsk4D+3PL/VjQ5gzg==", 96 | "dependencies": { 97 | "chalk": "^4.1.2", 98 | "lodash": "^4.17.21", 99 | "rxjs": "^7.8.1", 100 | "shell-quote": "^1.8.1", 101 | "supports-color": "^8.1.1", 102 | "tree-kill": "^1.2.2", 103 | "yargs": "^17.7.2" 104 | }, 105 | "bin": { 106 | "conc": "dist/bin/concurrently.js", 107 | "concurrently": "dist/bin/concurrently.js" 108 | }, 109 | "engines": { 110 | "node": ">=18" 111 | }, 112 | "funding": { 113 | "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" 114 | } 115 | }, 116 | "node_modules/emoji-regex": { 117 | "version": "8.0.0", 118 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 119 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" 120 | }, 121 | "node_modules/escalade": { 122 | "version": "3.1.1", 123 | "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", 124 | "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", 125 | "engines": { 126 | "node": ">=6" 127 | } 128 | }, 129 | "node_modules/get-caller-file": { 130 | "version": "2.0.5", 131 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 132 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 133 | "engines": { 134 | "node": "6.* || 8.* || >= 10.*" 135 | } 136 | }, 137 | "node_modules/has-flag": { 138 | "version": "4.0.0", 139 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 140 | "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 141 | "engines": { 142 | "node": ">=8" 143 | } 144 | }, 145 | "node_modules/is-fullwidth-code-point": { 146 | "version": "3.0.0", 147 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 148 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 149 | "engines": { 150 | "node": ">=8" 151 | } 152 | }, 153 | "node_modules/lodash": { 154 | "version": "4.17.21", 155 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 156 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 157 | }, 158 | "node_modules/require-directory": { 159 | "version": "2.1.1", 160 | "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 161 | "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", 162 | "engines": { 163 | "node": ">=0.10.0" 164 | } 165 | }, 166 | "node_modules/rxjs": { 167 | "version": "7.8.1", 168 | "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", 169 | "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", 170 | "dependencies": { 171 | "tslib": "^2.1.0" 172 | } 173 | }, 174 | "node_modules/shell-quote": { 175 | "version": "1.8.1", 176 | "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", 177 | "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", 178 | "funding": { 179 | "url": "https://github.com/sponsors/ljharb" 180 | } 181 | }, 182 | "node_modules/string-width": { 183 | "version": "4.2.3", 184 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 185 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 186 | "dependencies": { 187 | "emoji-regex": "^8.0.0", 188 | "is-fullwidth-code-point": "^3.0.0", 189 | "strip-ansi": "^6.0.1" 190 | }, 191 | "engines": { 192 | "node": ">=8" 193 | } 194 | }, 195 | "node_modules/strip-ansi": { 196 | "version": "6.0.1", 197 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 198 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 199 | "dependencies": { 200 | "ansi-regex": "^5.0.1" 201 | }, 202 | "engines": { 203 | "node": ">=8" 204 | } 205 | }, 206 | "node_modules/supports-color": { 207 | "version": "8.1.1", 208 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", 209 | "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", 210 | "dependencies": { 211 | "has-flag": "^4.0.0" 212 | }, 213 | "engines": { 214 | "node": ">=10" 215 | }, 216 | "funding": { 217 | "url": "https://github.com/chalk/supports-color?sponsor=1" 218 | } 219 | }, 220 | "node_modules/tree-kill": { 221 | "version": "1.2.2", 222 | "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", 223 | "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", 224 | "bin": { 225 | "tree-kill": "cli.js" 226 | } 227 | }, 228 | "node_modules/tslib": { 229 | "version": "2.6.2", 230 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", 231 | "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" 232 | }, 233 | "node_modules/wrap-ansi": { 234 | "version": "7.0.0", 235 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 236 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 237 | "dependencies": { 238 | "ansi-styles": "^4.0.0", 239 | "string-width": "^4.1.0", 240 | "strip-ansi": "^6.0.0" 241 | }, 242 | "engines": { 243 | "node": ">=10" 244 | }, 245 | "funding": { 246 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 247 | } 248 | }, 249 | "node_modules/y18n": { 250 | "version": "5.0.8", 251 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 252 | "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 253 | "engines": { 254 | "node": ">=10" 255 | } 256 | }, 257 | "node_modules/yargs": { 258 | "version": "17.7.2", 259 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", 260 | "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", 261 | "dependencies": { 262 | "cliui": "^8.0.1", 263 | "escalade": "^3.1.1", 264 | "get-caller-file": "^2.0.5", 265 | "require-directory": "^2.1.1", 266 | "string-width": "^4.2.3", 267 | "y18n": "^5.0.5", 268 | "yargs-parser": "^21.1.1" 269 | }, 270 | "engines": { 271 | "node": ">=12" 272 | } 273 | }, 274 | "node_modules/yargs-parser": { 275 | "version": "21.1.1", 276 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", 277 | "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", 278 | "engines": { 279 | "node": ">=12" 280 | } 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pact-workshop-js", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "postinstall": "concurrently \"npm ci --prefix consumer\" \"npm ci --prefix provider\"", 7 | "start": "concurrently \"npm start --prefix consumer\" \"npm start --prefix provider\"", 8 | "lint": "concurrently \"cd consumer/ && npx eslint .\" \"cd provider/ && npx eslint .\"" 9 | }, 10 | "dependencies": { 11 | "concurrently": "9.0.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /provider/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2018 15 | }, 16 | "rules": { 17 | } 18 | } -------------------------------------------------------------------------------- /provider/middleware/auth.middleware.js: -------------------------------------------------------------------------------- 1 | // 'Token' should be a valid ISO 8601 timestamp within the last hour 2 | const isValidAuthTimestamp = (timestamp) => { 3 | let diff = (new Date() - new Date(timestamp)) / 1000; 4 | return diff >= 0 && diff <= 3600 5 | }; 6 | 7 | const authMiddleware = (req, res, next) => { 8 | if (!req.headers.authorization) { 9 | return res.status(401).json({ error: "Unauthorized" }); 10 | } 11 | const timestamp = req.headers.authorization.replace("Bearer ", "") 12 | if (!isValidAuthTimestamp(timestamp)) { 13 | return res.status(401).json({ error: "Unauthorized" }); 14 | } 15 | next(); 16 | }; 17 | 18 | module.exports = authMiddleware; -------------------------------------------------------------------------------- /provider/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "product-service", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "test": "jest", 9 | "test:pact": "jest --testTimeout 30000 --testMatch \"**/*.pact.test.js\"" 10 | }, 11 | "dependencies": { 12 | "cors": "2.8.5", 13 | "express": "5.0.1" 14 | }, 15 | "devDependencies": { 16 | "eslint": "9.12.0", 17 | "jest": "29.7.0", 18 | "@pact-foundation/pact": "13.1.4" 19 | }, 20 | "license": "ISC" 21 | } 22 | -------------------------------------------------------------------------------- /provider/product/product.controller.js: -------------------------------------------------------------------------------- 1 | const ProductRepository = require("./product.repository"); 2 | 3 | const repository = new ProductRepository(); 4 | 5 | exports.getAll = async (req, res) => { 6 | res.send(await repository.fetchAll()) 7 | }; 8 | exports.getById = async (req, res) => { 9 | const product = await repository.getById(req.params.id); 10 | product ? res.send(product) : res.status(404).send({message: "Product not found"}) 11 | }; 12 | 13 | exports.repository = repository; -------------------------------------------------------------------------------- /provider/product/product.js: -------------------------------------------------------------------------------- 1 | class Product { 2 | constructor(id, type, name, version) { 3 | this.id = id; 4 | this.type = type; 5 | this.name = name; 6 | this.version = version 7 | } 8 | } 9 | 10 | module.exports = Product; -------------------------------------------------------------------------------- /provider/product/product.pact.test.js: -------------------------------------------------------------------------------- 1 | const { Verifier } = require('@pact-foundation/pact'); 2 | const controller = require('./product.controller'); 3 | const Product = require('./product'); 4 | 5 | // Setup provider server to verify 6 | const app = require('express')(); 7 | const authMiddleware = require('../middleware/auth.middleware'); 8 | app.use(authMiddleware); 9 | app.use(require('./product.routes')); 10 | const server = app.listen("8080"); 11 | 12 | describe("Pact Verification", () => { 13 | it("validates the expectations of ProductService", () => { 14 | const opts = { 15 | logLevel: "INFO", 16 | providerBaseUrl: "http://127.0.0.1:8080", 17 | provider: "ProductService", 18 | providerVersion: "1.0.0", 19 | providerVersionBranch: "test", 20 | consumerVersionSelectors: [{ 21 | latest: true 22 | }], 23 | pactBrokerUrl: process.env.PACT_BROKER_URL || "http://127.0.0.1:8000", 24 | pactBrokerUsername: process.env.PACT_BROKER_USERNAME || "pact_workshop", 25 | pactBrokerPassword: process.env.PACT_BROKER_PASSWORD || "pact_workshop", 26 | stateHandlers: { 27 | "product with ID 10 exists": () => { 28 | controller.repository.products = new Map([ 29 | ["10", new Product("10", "CREDIT_CARD", "28 Degrees", "v1")] 30 | ]); 31 | }, 32 | "products exist": () => { 33 | controller.repository.products = new Map([ 34 | ["09", new Product("09", "CREDIT_CARD", "Gem Visa", "v1")], 35 | ["10", new Product("10", "CREDIT_CARD", "28 Degrees", "v1")] 36 | ]); 37 | }, 38 | "no products exist": () => { 39 | controller.repository.products = new Map(); 40 | }, 41 | "product with ID 11 does not exist": () => { 42 | controller.repository.products = new Map(); 43 | }, 44 | }, 45 | requestFilter: (req, res, next) => { 46 | if (!req.headers["authorization"]) { 47 | next(); 48 | return; 49 | } 50 | req.headers["authorization"] = `Bearer ${new Date().toISOString()}`; 51 | next(); 52 | }, 53 | }; 54 | 55 | if (process.env.CI || process.env.PACT_PUBLISH_RESULTS) { 56 | Object.assign(opts, { 57 | publishVerificationResult: true, 58 | }); 59 | } 60 | 61 | return new Verifier(opts).verifyProvider().then(output => { 62 | console.log(output); 63 | }).finally(() => { 64 | server.close(); 65 | }); 66 | }) 67 | }); -------------------------------------------------------------------------------- /provider/product/product.repository.js: -------------------------------------------------------------------------------- 1 | const Product = require('./product'); 2 | 3 | class ProductRepository { 4 | 5 | constructor() { 6 | this.products = new Map([ 7 | ["09", new Product("09", "CREDIT_CARD", "Gem Visa", "v1")], 8 | ["10", new Product("10", "CREDIT_CARD", "28 Degrees", "v1")], 9 | ["11", new Product("11", "PERSONAL_LOAN", "MyFlexiPay", "v2")], 10 | ]); 11 | } 12 | 13 | async fetchAll() { 14 | return [...this.products.values()] 15 | } 16 | 17 | async getById(id) { 18 | return this.products.get(id); 19 | } 20 | } 21 | 22 | module.exports = ProductRepository; 23 | -------------------------------------------------------------------------------- /provider/product/product.routes.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const controller = require('./product.controller'); 3 | 4 | router.get("/product/:id", controller.getById); 5 | router.get("/products", controller.getAll); 6 | 7 | module.exports = router; -------------------------------------------------------------------------------- /provider/server.js: -------------------------------------------------------------------------------- 1 | const app = require('express')(); 2 | const cors = require('cors'); 3 | const routes = require('./product/product.routes'); 4 | const authMiddleware = require('./middleware/auth.middleware'); 5 | const port = 8080; 6 | 7 | const init = () => { 8 | app.use(cors()); 9 | app.use(routes); 10 | app.use(authMiddleware); 11 | return app.listen(port, () => console.log(`Provider API listening on port ${port}...`)); 12 | }; 13 | 14 | init(); --------------------------------------------------------------------------------