/pact-gen-ts

Generate pact contracts using TypeScript type definitions and custom JSDoc tags.

Primary LanguageTypeScriptMIT LicenseMIT

Pact-gen-ts

Pact-gen-ts is a tool for generating contracts using TypeScript type definitions and custom JSDoc tags.

It's an alternative to the pact-js package but without the necessity for writing separate tests. It provides automated, low maintenance and more flexible way to generate contracts according to Pact specification version 2.

Installation and usage

You can install pact-gen-ts using npm:

npm install pact-gen-ts --save-dev

or yarn:

yarn add --dev pact-gen-ts

Next you should create a minimal pacts.config.js configuration file in the root directory:

module.exports = {
    consumer: 'consumer-name',
    providers: [
        {
            provider: 'some-provider',
            files: ['src/api/**/*.ts'],
        },
    ],
};

where files property will be an array of glob patterns pointing to API functions definitions.

After that pact-gen-ts is ready, now you need to mark all API functions which will be analysed:

/**
 * @pact
 */
function fetchComments() {
    // ...
}

The last thing is to execute the command:

pact-gen-ts

which does the analysis and generates pacts in JSON format inside (by default) ./pacts directory.

Compatibility with TypeScript

Due to TypeScript's occasional changes to its compiler API and not following semantic versioning in their releases, the latest versions of pact-gen-ts can only guarantee compatibility with the latest versions of TypeScript.

If you're limited to historical versions of TypeScript, you should install a corresponding version of pact-gen-ts. The below table presents what TS versions pact-gen-ts will work with:

pact-gen-ts TypeScript
0.8 4.1 - 4.2
0.9 - 0.9.3 4.5 - 4.6
0.9.4 - 0.10.0 4.7 - 4.8
0.11.0 4.9
0.12.0 5.0
0.13.0 5.1
0.14.0 5.2 - 5.3
0.15.0 >=5.4

Configuration

Pact-gen-ts uses configuration stored in pacts.config.js file in project's root directory:

module.exports = {
    consumer: 'consumer-name',
    buildDir: 'pacts',
    verbose: true,
    providers: [
        {
            provider: 'provider-name',
            files: ['src/api/firstProvider/*.ts'],
            queryArrayFormat: 'indices',
            requestHeaders: {
                authorization: 'auth',
            },
            responseHeaders: {
                'Content-Type': 'application/json',
            },
        },
    ],
};

Options

Option Required Default Description
consumer Yes - Consumer's name
providers[].provider Yes - Provider's name
providers[].files Yes - Array of glob patterns where API functions are defined
providers[].requestHeaders No - Request headers shared across all requests
providers[].responseHeaders No - Response headers shared across all responses
providers[].queryArrayFormat No "brackets" Sets separator for array in query - possible options are "indices", "brackets", "comma" and "repeat" (source). The default value is brackets.
buildDir No ./pacts Directory where generated pacts will be placed
verbose No false If set to true additional information during pacts generating process will be logged

You can specify common config shared between providers in pacts.config.js:

module.exports = {
    commonConfigForProviders: {
        queryArrayFormat: 'indices',
        requestHeaders: {
            authorization: 'auth',
        },
        responseHeaders: {
            'Content-Type': 'application/json',
        },
    },
    providers: [
        {
            provider: 'first-provider',
            files: ['src/api1/**/*.ts'],
        },
        {
            provider: 'second-provider',
            files: ['src/api2/**/*.ts'],
        },
        {
            provider: 'third-provider',
            files: ['src/api3/**/*.ts'],
            // you can override common config in provider config
            queryArrayFormat: 'comma',
        },
    ],
};

Integrations

Axios - @pact-axios

Sets REST method, expected body for the current response, expected body for current request and query based on axios definitions.

/**
 * @pact
 * @pact-axios
 * @pact-path /api
 */
async function fetchComments(commentId: string) {
    const {data} = await axios.post<string>('/api', {commentId});
    // ...
}

IMPORTANT - If axios function does not return any type explicitly it is needed to set <void> as an axios return type

/**
 * @pact
 * @pact-axios
 * @pact-path /api
 */
async function fetchComments(commentId: string) {
    await axios.post<void>('/api', {commentId});
}

Pact interaction options

These JSDoc custom tags are used to adjust generated pact interactions.

@pact-method

Sets REST method (GET, POST, PUT, PATCH, DELETE etc.).

/**
 * @pact
 * @pact-method GET
 */
function fetchComments() {
    // ...
}

@pact-path

Sets path.

/**
 * @pact
 * @pact-path /api/images/100
 */
function fetchImage(imageId: number) {
    // ...
}

@pact-description

Sets description, if not provided, description is set using name of the function / variable / property.

/**
 * @pact
 * @pact-description "request to get comments"
 */
function fetchComments() {
    // ...
}

@pact-response-status

Sets response status, if not provided, it is set based on given HTTP method.

/**
 * @pact
 * @pact-response-status 200
 */
function fetchComments() {
    // ...
}

@pact-request-header

Adds a header to the current request, can override option defined in pacts.config.js.

/**
 * @pact
 * @pact-request-header "Content-Type" "application/pdf"
 */
function fetchImage(imageId: number) {
    // ...
}

@pact-response-header

Adds a header to the current response, can override option defined in pacts.config.js.

/**
 * @pact
 * @pact-response-header "Content-Type" "application/pdf"
 */
function fetchImage(imageId: number) {
    // ...
}

@pact-response-body

Sets expected body for the current response.

/**
 * @pact
 */
async function fetchComments() {
    // ...
    const response = await axios.get<string>('/api');
    /** @pact-response-body */
    const data = response.data;
    // ...
}

IMPORTANT - JSDoc has to be applied to separate variable - not directly to axios response

async function fetchComments() {
    // ...
    /** @pact-response-body */ -WRONG!;
    const response = await axios.get<string>('/api');

    /** @pact-response-body */ -CORRECT;
    const data = response.data;
    // ...
}

@pact-request-body

Sets expected body for current request.

function addComment(/** @pact-request-body */ newComment: NewComment) {
    // ...
}

interface NewComment {
    content: string;
    postId: string;
}

or

function addComment(postId: string, commentContent: string) {
    /** @pact-request-body */
    const newComment = {
        postId,
        commentContent,
    };
    // ...
}

@pact-query

Sets query, IMPORTANT - JSDoc tag has to be applied to an object - not a primitive value.

Array separator format can be set using queryArrayFormat in providers options.

function fetchComments(/** @pact-query */ query: Query) {
    // ...
}

interface Query {
    fromUser: string;
    postId: string;
}

or

function fetchComments(pageNo: string) {
    /** @pact-query */
    const params = {
        pageNo,
    };
    // ...
}

Pact matchers

Typescript types can describe the shape of the data and define possible values a variable can store. Pacts definition require specific values, that's why for some individual cases additional information needs to be added.

For example a type string without any modifications will be replaced with simple text which can be later matched by type. Sometimes that's not enough - the matcher needs to be more specific, for instance instead of simple text we need a string in a particular format like name@example.com - that's where a @pact-matcher tag is used.

Pact-matchers are used in the type/interface definition:

interface CommentDTO {
    id: number;
    /** @pact-matcher email */
    user: string;
}

Provided common matchers:

Pact matcher Result
/** @pact-matcher email */ email@example.com
/** @pact-matcher iso-date */ 2021-04-13
/** @pact-matcher iso-datetime */ 2021-04-13T10:14:53+01:00
/** @pact-matcher iso-datetime-with-millis */ 2021-04-13T10:14:53.123+01:00
/** @pact-matcher iso-time */ T10.14.53.342Z
/** @pact-matcher timestamp */ Tue, 13 Apr 2021 10:14:53 -0400
/** @pact-matcher uuid */ ce11b6e-d8e1-11e7-9296-cec278b6b50a
/** @pact-matcher ipv4 */ 127.0.0.13
/** @pact-matcher ipv6 */ ::ffff:192.0.2.128
/** @pact-matcher hex */ A4C3Ff

If that's not enough you can easily provide own value using /** @pact-example */:

interface Address {
    city: string;
    address: string;
    /** @pact-example 99-400 */
    postCode: string;
    /** @pact-example 45 */
    age: number;
}