pact-foundation/pact-js

View on GitHub
src/v4/http/index.ts

Summary

Maintainability
B
6 hrs
Test Coverage
/* eslint-disable */
import { ConsumerInteraction, ConsumerPact } from '@pact-foundation/pact-core';
import { JsonMap } from '../../common/jsonTypes';
import { forEachObjIndexed } from 'ramda';
import { Path, TemplateHeaders, TemplateQuery, V3MockServer } from '../../v3';
import { Matcher, matcherValueOrString } from '../../v3/matchers';
import {
  PactV4Options,
  PluginConfig,
  V4InteractionWithCompleteRequest,
  V4InteractionWithPlugin,
  V4InteractionwithRequest,
  V4InteractionWithResponse,
  V4MockServer,
  V4Request,
  V4RequestBuilder,
  V4RequestBuilderFunc,
  V4ResponseBuilder,
  V4ResponseBuilderFunc,
  V4UnconfiguredInteraction,
  V4Response,
  V4InteractionWithPluginRequest,
  V4PluginResponseBuilderFunc,
  V4InteractionWithPluginResponse,
  V4RequestWithPluginBuilder,
  V4ResponseWithPluginBuilder,
  V4PluginRequestBuilderFunc,
  TestFunction,
} from './types';
import fs = require('fs');
import {
  filterMissingFeatureFlag,
  generateMockServerError,
} from '../../v3/display';
import logger from '../../common/logger';

type TemplateHeaderArrayValue = string[] | Matcher<string>[];

export class UnconfiguredInteraction implements V4UnconfiguredInteraction {
  // tslint:disable:no-empty-function
  constructor(
    protected pact: ConsumerPact,
    protected interaction: ConsumerInteraction,
    protected opts: PactV4Options,
    protected cleanupFn: () => void
  ) {}

  uponReceiving(description: string): V4UnconfiguredInteraction {
    this.interaction.uponReceiving(description);

    return this;
  }

  given(state: string, parameters?: JsonMap): V4UnconfiguredInteraction {
    if (parameters) {
      this.interaction.givenWithParams(state, JSON.stringify(parameters));
    } else {
      this.interaction.given(state);
    }

    return this;
  }

  withCompleteRequest(request: V4Request): V4InteractionWithCompleteRequest {
    return new InteractionWithCompleteRequest(
      this.pact,
      this.interaction,
      this.opts,
      this.cleanupFn
    );
  }

  withRequest(
    method: string,
    path: Path,
    builder?: V4RequestBuilderFunc
  ): V4InteractionwithRequest {
    this.interaction.withRequest(method, matcherValueOrString(path));

    if (builder) {
      builder(new RequestBuilder(this.interaction));
    }
    return new InteractionwithRequest(
      this.pact,
      this.interaction,
      this.opts,
      this.cleanupFn
    );
  }

  usingPlugin(config: PluginConfig): V4InteractionWithPlugin {
    this.pact.addPlugin(config.plugin, config.version);

    return new InteractionWithPlugin(
      this.pact,
      this.interaction,
      this.opts,
      this.cleanupFn
    );
  }
}

export class InteractionWithCompleteRequest
  implements V4InteractionWithCompleteRequest
{
  constructor(
    private pact: ConsumerPact,
    private interaction: ConsumerInteraction,
    private opts: PactV4Options,
    protected cleanupFn: () => void
  ) {
    throw Error('V4InteractionWithCompleteRequest is unimplemented');
  }

  withCompleteResponse(response: V4Response): V4InteractionWithResponse {
    return new InteractionWithResponse(this.pact, this.opts, this.cleanupFn);
  }
}

export class InteractionwithRequest implements V4InteractionwithRequest {
  // tslint:disable:no-empty-function
  constructor(
    private pact: ConsumerPact,
    private interaction: ConsumerInteraction,
    private opts: PactV4Options,
    protected cleanupFn: () => void
  ) {}

  willRespondWith(status: number, builder?: V4ResponseBuilderFunc) {
    this.interaction.withStatus(status);

    if (typeof builder === 'function') {
      builder(new ResponseBuilder(this.interaction));
    }

    return new InteractionWithResponse(this.pact, this.opts, this.cleanupFn);
  }
}

export class RequestBuilder implements V4RequestBuilder {
  // tslint:disable:no-empty-function
  constructor(protected interaction: ConsumerInteraction) {}

  query(query: TemplateQuery) {
    forEachObjIndexed((v, k) => {
      if (Array.isArray(v)) {
        (v as unknown[]).forEach((vv, i) => {
          this.interaction.withQuery(k, i, matcherValueOrString(vv));
        });
      } else {
        this.interaction.withQuery(k, 0, matcherValueOrString(v));
      }
    }, query);

    return this;
  }

  headers(headers: TemplateHeaders) {
    forEachObjIndexed((v, k) => {
      if (Array.isArray(v)) {
        (v as TemplateHeaderArrayValue).forEach(
          (header: string | Matcher<string>, index: number) => {
            this.interaction.withRequestHeader(
              `${k}`,
              index,
              matcherValueOrString(header)
            );
          }
        );
      } else {
        this.interaction.withRequestHeader(`${k}`, 0, matcherValueOrString(v));
      }
    }, headers);

    return this;
  }

  jsonBody(body: unknown) {
    this.interaction.withRequestBody(
      matcherValueOrString(body),
      'application/json'
    );
    return this;
  }

  binaryFile(contentType: string, file: string) {
    const body = readBinaryData(file);
    this.interaction.withRequestBinaryBody(body, contentType);

    return this;
  }

  multipartBody(contentType: string, file: string, mimePartName: string) {
    this.interaction.withRequestMultipartBody(contentType, file, mimePartName);

    return this;
  }

  body(contentType: string, body: Buffer) {
    this.interaction.withRequestBinaryBody(body, contentType);

    return this;
  }
}

export class ResponseBuilder implements V4ResponseBuilder {
  protected interaction: ConsumerInteraction;

  constructor(interaction: ConsumerInteraction) {
    this.interaction = interaction;
  }

  headers(headers: TemplateHeaders) {
    forEachObjIndexed((v, k) => {
      this.interaction.withResponseHeader(`${k}`, 0, matcherValueOrString(v));
    }, headers);

    return this;
  }

  jsonBody(body: unknown) {
    this.interaction.withResponseBody(
      matcherValueOrString(body),
      'application/json'
    );
    return this;
  }

  binaryFile(contentType: string, file: string) {
    const body = readBinaryData(file);
    this.interaction.withResponseBinaryBody(body, contentType);

    return this;
  }

  multipartBody(contentType: string, file: string, mimePartName: string) {
    this.interaction.withResponseMultipartBody(contentType, file, mimePartName);

    return this;
  }

  body(contentType: string, body: Buffer) {
    this.interaction.withResponseBinaryBody(body, contentType);

    return this;
  }
}

export class InteractionWithResponse implements V4InteractionWithResponse {
  // tslint:disable:no-empty-function
  constructor(
    private pact: ConsumerPact,
    private opts: PactV4Options,
    protected cleanupFn: () => void
  ) {}

  async executeTest<T>(testFn: TestFunction<T>) {
    return executeTest(this.pact, this.opts, testFn, this.cleanupFn);
  }
}

export class InteractionWithPlugin implements V4InteractionWithPlugin {
  // tslint:disable:no-empty-function
  constructor(
    private pact: ConsumerPact,
    private interaction: ConsumerInteraction,
    private opts: PactV4Options,
    protected cleanupFn: () => void
  ) {}

  // Multiple plugins are allowed
  usingPlugin(config: PluginConfig): V4InteractionWithPlugin {
    this.pact.addPlugin(config.plugin, config.version);

    return this;
  }

  withRequest(
    method: string,
    path: Path,
    builder?: V4PluginRequestBuilderFunc
  ): V4InteractionWithPluginRequest {
    this.interaction.withRequest(method, matcherValueOrString(path));

    if (typeof builder === 'function') {
      builder(new RequestWithPluginBuilder(this.interaction));
    }
    return new InteractionWithPluginRequest(
      this.pact,
      this.interaction,
      this.opts,
      this.cleanupFn
    );
  }
}

export class InteractionWithPluginRequest
  implements V4InteractionWithPluginRequest
{
  // tslint:disable:no-empty-function
  constructor(
    private pact: ConsumerPact,
    private interaction: ConsumerInteraction,
    private opts: PactV4Options,
    protected cleanupFn: () => void
  ) {}

  willRespondWith(
    status: number,
    builder?: V4PluginResponseBuilderFunc
  ): V4InteractionWithPluginResponse {
    this.interaction.withStatus(status);

    if (typeof builder === 'function') {
      builder(new ResponseWithPluginBuilder(this.interaction));
    }

    return new InteractionWithPluginResponse(
      this.pact,
      this.opts,
      this.cleanupFn
    );
  }
}

export class RequestWithPluginBuilder
  extends RequestBuilder
  implements V4RequestWithPluginBuilder
{
  pluginContents(
    contentType: string,
    contents: string
  ): V4RequestWithPluginBuilder {
    this.interaction.withPluginRequestInteractionContents(
      contentType,
      contents
    );

    return this;
  }
}

export class ResponseWithPluginBuilder
  extends ResponseBuilder
  implements V4ResponseWithPluginBuilder
{
  pluginContents(contentType: string, contents: string): V4ResponseBuilder {
    this.interaction.withPluginResponseInteractionContents(
      contentType,
      contents
    );
    return this;
  }
}

export class InteractionWithPluginResponse
  implements V4InteractionWithPluginResponse
{
  // tslint:disable:no-empty-function
  constructor(
    private pact: ConsumerPact,
    private opts: PactV4Options,
    protected cleanupFn: () => void
  ) {}

  async executeTest<T>(testFn: (mockServer: V4MockServer) => Promise<T>) {
    return executeTest(this.pact, this.opts, testFn, this.cleanupFn);
  }
}
const readBinaryData = (file: string): Buffer => {
  try {
    const body = fs.readFileSync(file);

    return body;
  } catch (e) {
    throw new Error(`unable to read file for binary payload : ${e.message}`);
  }
};

const cleanup = (
  success: boolean,
  pact: ConsumerPact,
  opts: PactV4Options,
  server: V3MockServer,
  cleanupFn: () => void
) => {
  if (success) {
    pact.writePactFile(opts.dir || './pacts');
  }
  pact.cleanupMockServer(server.port);
  pact.cleanupPlugins();
  cleanupFn();
};

const executeTest = async <T>(
  pact: ConsumerPact,
  opts: PactV4Options,
  testFn: TestFunction<T>,
  cleanupFn: () => void
) => {
  const scheme = opts.tls ? 'https' : 'http';
  const host = opts.host || '127.0.0.1';
  const port = pact.createMockServer(host, opts.port || 0, false);

  const server = { port, url: `${scheme}://${host}:${port}`, id: 'unknown' };
  let val: T | undefined;
  let error: Error | undefined;

  try {
    val = await testFn(server);
  } catch (e) {
    error = e;
  }

  const matchingResults = pact.mockServerMismatches(port);
  const errors = filterMissingFeatureFlag(matchingResults);
  const success = pact.mockServerMatchedSuccessfully(port);

  // Scenario: Pact validation failed
  if (!success && errors.length > 0) {
    let errorMessage = 'Test failed for the following reasons:';
    errorMessage += `\n\n  ${generateMockServerError(matchingResults, '\t')}`;

    cleanup(false, pact, opts, server, cleanupFn);

    // If the tests throws an error, we need to rethrow the error, but print out
    // any additional mock server errors to help the user understand what happened
    // (The proximate cause here is often the HTTP 500 from the mock server,
    // where the HTTP client then throws)
    if (error) {
      logger.error(errorMessage);
      throw error;
    }

    // Test didn't throw, so we need to ensure the test fails
    return Promise.reject(new Error(errorMessage));
  }

  // Scenario: test threw an error, but Pact validation was OK (error in client or test)
  if (error) {
    cleanup(false, pact, opts, server, cleanupFn);
    throw error;
  }

  // Scenario: Pact validation passed, test didn't throw - return the callback value
  cleanup(true, pact, opts, server, cleanupFn);

  return val;
};