Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

pactjs-utils-zod-to-pact.md 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. # Pact.js Utils Zod to Pact
  2. ## Principle
  3. Use `zodToPactMatchers` from `@seontechnologies/pactjs-utils` to derive Pact V3 matchers directly from a Zod schema so you never maintain two representations of the same response shape. The schema is the source of truth for types; plain example values (or `.openapi({ example })` metadata) supply the concrete example data.
  4. ## Rationale
  5. ### Problems with hand-written matcher helpers
  6. - **Duplication**: Teams that already define response shapes in Zod (or generate OpenAPI from Zod) then redefine the same shape again as hand-written `{ id: integer(...), name: string(...) }` matcher objects.
  7. - **Silent drift**: Every schema change must be applied in both places; miss one and the contract drifts silently from the real response shape.
  8. - **Boilerplate helpers per test file**: Consumer tests end up with local `propMatcherFoo(x) => ({ ... })` helpers that mirror the type exactly.
  9. - **Over-specification**: Importing the provider's full 20-field schema produces a contract that forces the provider to return every field — breaking consumer-driven testing's core benefit (consumer only asserts what it reads).
  10. ### Solutions
  11. - **`zodToPactMatchers(schema, example)`** — walks a Zod schema and emits the right `MatchersV3.*` call per field (`string()`, `integer()`, `decimal()`, `boolean()`, `nullValue()`, `eachLike(...)` for arrays, recursive objects, first option for unions, first value for enums, literal-typed matchers for literals).
  12. - **Three-step example resolution**: (1) the `example` arg wins, (2) `.openapi({ example })` metadata (if `@asteasolutions/zod-to-openapi` is installed), (3) a type-appropriate default (`'string'`, `1.0`, `true`, no-arg `integer()`).
  13. - **Consumer-curated schemas**: You choose which schema to pass, so you can include only the fields the consumer actually reads — keeping contracts lean and consumer-driven.
  14. ## Pattern Examples
  15. ### Example 1: Consumer-curated schema (mandatory pattern)
  16. ```typescript
  17. // pact/http/helpers/consumer-schemas.ts
  18. import { z } from 'zod';
  19. // Only the fields this consumer actually reads — NOT the shared full-response schema
  20. export const ConsumerMovieSchema = z.object({
  21. id: z.number().int(),
  22. name: z.string(),
  23. year: z.number().int(),
  24. rating: z.number(),
  25. director: z.string(),
  26. });
  27. ```
  28. ### Example 2: Replacing hand-written matcher helpers
  29. ```typescript
  30. // ❌ Before — hand-written helper duplicates the shape defined in Movie type
  31. const propMatcherNoId = (movie: Omit<Movie, 'id'>) => ({
  32. name: string(movie.name),
  33. year: integer(movie.year),
  34. rating: decimal(movie.rating),
  35. director: string(movie.director),
  36. });
  37. await pact
  38. .addInteraction()
  39. .given('No movies exist')
  40. .uponReceiving('a request to add a new movie')
  41. .withRequest('POST', '/movies', setJsonContent({ body: movieWithoutId }))
  42. .willRespondWith(
  43. 200,
  44. setJsonContent({
  45. body: {
  46. status: 200,
  47. data: { id: integer(), ...propMatcherNoId(movieWithoutId) },
  48. },
  49. }),
  50. );
  51. ```
  52. ```typescript
  53. // ✅ After — schema defines types, plain object provides examples
  54. import { zodToPactMatchers, setJsonContent } from '@seontechnologies/pactjs-utils';
  55. import { ConsumerMovieSchema } from '../helpers/consumer-schemas';
  56. await pact
  57. .addInteraction()
  58. .given('No movies exist')
  59. .uponReceiving('a request to add a new movie')
  60. .withRequest('POST', '/movies', setJsonContent({ body: movieWithoutId }))
  61. .willRespondWith(
  62. 200,
  63. setJsonContent({
  64. body: {
  65. status: 200,
  66. data: zodToPactMatchers(ConsumerMovieSchema, { id: 1, ...movieWithoutId }),
  67. },
  68. }),
  69. );
  70. ```
  71. ### Example 3: Array responses with `eachLike`
  72. ```typescript
  73. import { PactV4, MatchersV3 } from '@pact-foundation/pact';
  74. import { zodToPactMatchers, setJsonContent } from '@seontechnologies/pactjs-utils';
  75. import { ConsumerMovieSchema } from '../helpers/consumer-schemas';
  76. const { eachLike } = MatchersV3;
  77. const pact = new PactV4({ consumer: 'Movies Web', provider: 'Movies API' });
  78. const movie = { id: 1, name: 'My movie', year: 1999, rating: 8.5, director: 'John Doe' };
  79. await pact
  80. .addInteraction()
  81. .given('Movies exist')
  82. .uponReceiving('a request for all movies')
  83. .withRequest('GET', '/movies')
  84. .willRespondWith(
  85. 200,
  86. setJsonContent({
  87. body: {
  88. status: 200,
  89. data: eachLike(zodToPactMatchers(ConsumerMovieSchema, movie) as Parameters<typeof eachLike>[0]),
  90. },
  91. }),
  92. );
  93. // data expands to: eachLike({ id: integer(1), name: string('My movie'), year: integer(1999), rating: decimal(8.5), director: string('John Doe') })
  94. ```
  95. ### Example 4: Message Pact tests (Kafka / async)
  96. ```typescript
  97. import { PactV4, MatchersV3 } from '@pact-foundation/pact';
  98. import { zodToPactMatchers } from '@seontechnologies/pactjs-utils';
  99. import { ConsumerMovieSchema } from '../../http/helpers/consumer-schemas';
  100. const { string } = MatchersV3;
  101. // Schema-derived matchers — no manual matcher construction, no outer like() wrapper
  102. const movieValue = zodToPactMatchers(ConsumerMovieSchema, {
  103. id: 1,
  104. name: 'Inception',
  105. year: 2010,
  106. rating: 8.8,
  107. director: 'Christopher Nolan',
  108. });
  109. await messagePact
  110. .addAsynchronousInteraction()
  111. .given('An existing movie exists')
  112. .expectsToReceive('a movie-created event', (builder) => {
  113. builder.withJSONContent({
  114. topic: string('movie-created'),
  115. messages: [{ key: string('1'), value: movieValue }],
  116. });
  117. });
  118. ```
  119. Note: `zodToPactMatchers` on an object schema already wraps each field in the right matcher, so the extra `like()` wrapper from hand-written versions is not needed — each field carries its own type constraint.
  120. ### Example 5: OpenAPI example metadata (optional peer)
  121. ```typescript
  122. import { z } from 'zod';
  123. import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
  124. extendZodWithOpenApi(z);
  125. const MovieSchema = z.object({
  126. name: z.string().openapi({ example: 'Inception' }),
  127. year: z.number().int().openapi({ example: 2010 }),
  128. });
  129. // No second argument needed — examples come from the schema itself
  130. zodToPactMatchers(MovieSchema);
  131. // → { name: string('Inception'), year: integer(2010) }
  132. ```
  133. ## Zod to Pact V3 Mapping
  134. | Zod type | Pact V3 matcher |
  135. | --------------------------------------------- | ----------------------------------------- |
  136. | `z.string()` | `string(example ?? 'string')` |
  137. | `z.number().int()` | `integer(example)` (no-arg if no example) |
  138. | `z.number()` | `decimal(example ?? 1.0)` |
  139. | `z.boolean()` | `boolean(example ?? true)` |
  140. | `z.null()` | `nullValue()` |
  141. | `z.object({...})` | recursive object of field matchers |
  142. | `z.array(...)` | `eachLike(itemMatchers)` |
  143. | `z.union([...])` | first option's matcher |
  144. | `z.literal('x')` / number / bool | typed matcher with literal value |
  145. | `z.enum([...])` | `string(firstValue)` |
  146. | `z.optional()` / `.nullable()` / `.default()` | unwraps to the inner schema |
  147. | anything else | `like(example ?? null)` fallback |
  148. ## Key Points
  149. - **Consumer-curated schema is mandatory**: Define schemas that describe only what the consumer actually reads. Do **not** pass the shared full-response schema, and do **not** `import` the provider-side schema — that turns contract tests into schema tests and blocks the provider from deprecating unused fields.
  150. - **Example precedence**: `example` argument > `.openapi({ example })` metadata > type default. The example only sets the placeholder value; Pact matchers check type/shape, not exact values.
  151. - **Optional peer**: `@asteasolutions/zod-to-openapi` is an optional peer dependency. If it's not installed, openapi-example extraction silently becomes a no-op and only the `example` argument / defaults are used.
  152. - **Optional peer (zod)**: `zod` itself is declared as an optional peer of `@seontechnologies/pactjs-utils` so consumers who don't use `zodToPactMatchers` don't need it; consumers who do use it must have zod installed.
  153. - **Object wrapping**: When passing an object result into `eachLike(...)`, cast to `Parameters<typeof eachLike>[0]` — `zodToPactMatchers` returns `unknown` by design to stay compatible with both primitive and composite matcher shapes.
  154. - **Arrays without examples**: If the example array is empty, the first item's field matchers are derived from the schema (and `.openapi({ example })` metadata, if present).
  155. - **No extra `like()` wrapper**: For objects returned from `zodToPactMatchers`, do not wrap the whole object in `like()`; each field is already a matcher.
  156. - **Works for HTTP and message pacts**: The same function produces matchers for request/response bodies and for Kafka / async message payloads.
  157. - **TypeScript**: Import `z` as a runtime value when defining schemas (`import { z } from 'zod'`). If you need a schema type in helper signatures, import it separately (for example, `import type { ZodTypeAny } from 'zod'`).
  158. ## Related Fragments
  159. - `pactjs-utils-overview.md` — installation, utility table, decision tree
  160. - `pactjs-utils-consumer-helpers.md` — `createProviderState`, `setJsonContent`, `setJsonBody`
  161. - `pactjs-utils-provider-verifier.md` — `buildVerifierOptions` integration
  162. - `contract-testing.md` — foundational patterns with raw Pact.js, Provider Scrutiny Protocol (required fields / enums / data types / nested structures)
  163. ## Anti-Patterns
  164. ### Wrong: Passing the provider's full response schema
  165. ```typescript
  166. // ❌ Importing the shared server-side schema forces the provider to return every field
  167. import { FullMovieSchema } from '@shared/schemas/movie'; // 20 fields
  168. data: zodToPactMatchers(FullMovieSchema, movie);
  169. ```
  170. This creates a contract that requires the provider to return all 20 fields, even the ones this consumer never reads — breaking consumer-driven testing and blocking future field deprecation.
  171. ### Right: Consumer-curated schema beside the pact tests
  172. ```typescript
  173. // ✅ pact/http/helpers/consumer-schemas.ts — only the fields this consumer reads
  174. export const ConsumerMovieSchema = z.object({
  175. id: z.number().int(),
  176. name: z.string(),
  177. year: z.number().int(),
  178. rating: z.number(),
  179. director: z.string(),
  180. });
  181. data: zodToPactMatchers(ConsumerMovieSchema, movie);
  182. ```
  183. ### Wrong: Hand-written matcher helper duplicating the schema
  184. ```typescript
  185. // ❌ Local helper that mirrors the TS type — drifts silently on every schema change
  186. const propMatcherNoId = (movie: Omit<Movie, 'id'>) => ({
  187. name: string(movie.name),
  188. year: integer(movie.year),
  189. rating: decimal(movie.rating),
  190. director: string(movie.director),
  191. });
  192. ```
  193. ### Right: `zodToPactMatchers` with a consumer-curated schema
  194. ```typescript
  195. // ✅ Schema is the single source of truth; plain object supplies examples
  196. data: zodToPactMatchers(ConsumerMovieSchema, { id: 1, ...movieWithoutId });
  197. ```
  198. ### Wrong: Wrapping the whole object result in `like()`
  199. ```typescript
  200. // ❌ Redundant — each field is already a matcher
  201. value: like(zodToPactMatchers(ConsumerMovieSchema, movie));
  202. ```
  203. ### Right: Use the object directly
  204. ```typescript
  205. // ✅ Each field carries its own type constraint
  206. value: zodToPactMatchers(ConsumerMovieSchema, movie);
  207. ```
  208. _Source: @seontechnologies/pactjs-utils library, pactjs-utils docs (`docs/zod-to-pact/`), pact-js consumer sample repos, Pact docs on consumer-driven contracts_