pact-foundation/pact-js

View on GitHub
src/v3/matchers.ts

Summary

Maintainability
C
1 day
Test Coverage
import { isNil, pickBy, times } from 'ramda';
import RandExp from 'randexp';

import {
  ArrayContainsMatcher,
  DateTimeMatcher,
  Matcher,
  MaxLikeMatcher,
  MinLikeMatcher,
  ProviderStateInjectedValue,
  RulesMatcher,
  V3RegexMatcher,
} from './types';

import { AnyJson, JsonMap } from '../common/jsonTypes';

export * from './types';

export function isMatcher(x: unknown): x is Matcher<unknown> {
  return (
    x != null &&
    (x as Matcher<unknown>)['pact:matcher:type'] !== undefined &&
    (x as Matcher<unknown>).value !== undefined
  );
}

/**
 * Value must match the given template
 * @param template Template to base the comparison on
 */
export const like = <T>(template: T): Matcher<T> => ({
  'pact:matcher:type': 'type',
  value: template,
});

/**
 * Object where the key itself is ignored, but the value template must match.
 *
 * @deprecated use eachKeyMatches or eachValueMatches
 * @param keyTemplate Example key to use
 * @param template Example value template to base the comparison on
 */
export const eachKeyLike = <T>(
  keyTemplate: string,
  template: T
): Matcher<T> => ({
  'pact:matcher:type': 'values',
  value: {
    [keyTemplate]: template,
  },
});

/**
 * Object where the _keys_ must match the supplied matchers.
 * The values for each key are ignored. That is, there can be 0 or more keys
 * with any valid JSON identifier, so long as the names of the keys match the constraints.
 *
 * @param example Example object with key/values e.g. `{ foo: 'bar', baz: 'qux'}`
 * @param matchers Matchers to apply to each key
 */
export const eachKeyMatches = (
  example: Record<string, unknown>,
  matchers: Matcher<string> | Matcher<string>[] = like('key')
): RulesMatcher<unknown> => ({
  'pact:matcher:type': 'eachKey',
  rules: Array.isArray(matchers) ? matchers : [matchers],
  value: example,
});

/**
 * Object where the _values_ must match the supplied matchers.
 * The names of the keys are ignored. That is, there can be 0 or more keys
 * with any valid JSON identifier, so long as the values match the constraints.
 *
 * @param example Example object with key/values e.g. `{ foo: 'bar', baz: 'qux'}`
 * @param matchers Matchers to apply to each value
 */
export const eachValueMatches = <T>(
  example: Record<string, T>,
  matchers: Matcher<T> | Matcher<T>[]
): RulesMatcher<T> => ({
  'pact:matcher:type': 'eachValue',
  rules: Array.isArray(matchers) ? matchers : [matchers],
  value: example,
  // Unsure if the full object is provided, or just a template k/v pair
  // value: {
  //   [keyTemplate]: template,
  // },
});

/**
 * Array where each element must match the given template
 * @param template Template to base the comparison on
 * @param min Minimum number of elements required in the array
 */
export const eachLike = <T>(template: T, min = 1): MinLikeMatcher<T[]> => {
  const elements = min;
  return {
    min,
    'pact:matcher:type': 'type',
    value: times(() => template, elements),
  };
};

/**
 * An array that has to have at least one element and each element must match the given template
 * @param template Template to base the comparison on
 * @param count Number of examples to generate, defaults to one
 */
export const atLeastOneLike = <T>(
  template: T,
  count = 1
): MinLikeMatcher<T[]> => ({
  min: 1,
  'pact:matcher:type': 'type',
  value: times(() => template, count),
});

/**
 * An array that has to have at least the required number of elements and each element must match the given template
 * @param template Template to base the comparison on
 * @param min Minimum number of elements required in the array
 * @param count Number of examples to generate, defaults to min
 */
export const atLeastLike = <T>(
  template: T,
  min: number,
  count?: number
): MinLikeMatcher<T[]> => {
  const elements = count || min;
  if (count && count < min) {
    throw new Error(
      `atLeastLike has a minimum of ${min} but ${count} elements were requested.` +
        ` Make sure the count is greater than or equal to the min.`
    );
  }

  return {
    min,
    'pact:matcher:type': 'type',
    value: times(() => template, elements),
  };
};

/**
 * An array that has to have at most the required number of elements and each element must match the given template
 * @param template Template to base the comparison on
 * @param max Maximum number of elements required in the array
 * @param count Number of examples to generate, defaults to one
 */
export const atMostLike = <T>(
  template: T,
  max: number,
  count?: number
): MaxLikeMatcher<T[]> => {
  const elements = count || 1;
  if (count && count > max) {
    throw new Error(
      `atMostLike has a maximum of ${max} but ${count} elements where requested.` +
        ` Make sure the count is less than or equal to the max.`
    );
  }

  return {
    max,
    'pact:matcher:type': 'type',
    value: times(() => template, elements),
  };
};

/**
 * An array whose size is constrained to the minimum and maximum number of elements and each element must match the given template
 * @param template Template to base the comparison on
 * @param min Minimum number of elements required in the array
 * @param max Maximum number of elements required in the array
 * @param count Number of examples to generate, defaults to one
 */
export const constrainedArrayLike = <T>(
  template: T,
  min: number,
  max: number,
  count?: number
): MinLikeMatcher<T[]> & MaxLikeMatcher<T[]> => {
  const elements = count || min;
  if (count) {
    if (count < min) {
      throw new Error(
        `constrainedArrayLike has a minimum of ${min} but ${count} elements where requested.` +
          ` Make sure the count is greater than or equal to the min.`
      );
    } else if (count > max) {
      throw new Error(
        `constrainedArrayLike has a maximum of ${max} but ${count} elements where requested.` +
          ` Make sure the count is less than or equal to the max.`
      );
    }
  }

  return {
    min,
    max,
    'pact:matcher:type': 'type',
    value: times(() => template, elements),
  };
};

/**
 * Value must be a boolean
 * @param b Boolean example value. Defaults to true if unsupplied
 */
export const boolean = (b = true): Matcher<boolean> => ({
  'pact:matcher:type': 'type',
  value: b,
});

/**
 * Value must be an integer (must be a number and have no decimal places)
 * @param int Example value. If omitted a random value will be generated.
 */
export const integer = (int?: number): Matcher<number> => {
  if (Number.isInteger(int)) {
    return {
      'pact:matcher:type': 'integer',
      value: int,
    };
  }
  if (int) {
    throw new Error(
      `The integer matcher was passed '${int}' which is not an integer.`
    );
  }

  return {
    'pact:generator:type': 'RandomInt',
    'pact:matcher:type': 'integer',
    value: 101,
  };
};

/**
 * Value must be a decimal number (must be a number and have decimal places)
 * @param num Example value. If omitted a random value will be generated.
 */
export const decimal = (num?: number): Matcher<number> => {
  if (Number.isFinite(num)) {
    return {
      'pact:matcher:type': 'decimal',
      value: num,
    };
  }
  if (num) {
    throw new Error(
      `The decimal matcher was passed '${num}' which is not a number.`
    );
  }
  return {
    'pact:generator:type': 'RandomDecimal',
    'pact:matcher:type': 'decimal',
    value: 12.34,
  };
};

/**
 * Value must be a number
 * @param num Example value. If omitted a random integer value will be generated.
 */
export function number(num?: number): Matcher<number> {
  if (num) {
    return {
      'pact:matcher:type': 'number',
      value: num,
    };
  }
  return {
    'pact:generator:type': 'RandomInt',
    'pact:matcher:type': 'number',
    value: 1234,
  };
}

/**
 * Value must be a string
 * @param str Example value
 */
export function string(str = 'some string'): Matcher<string> {
  return {
    'pact:matcher:type': 'type',
    value: str,
  };
}

/**
 * Value that must match the given regular expression
 * @param pattern Regular Expression to match
 * @param str Example value
 */
export function regex(pattern: RegExp | string, str: string): V3RegexMatcher {
  if (pattern instanceof RegExp) {
    return {
      'pact:matcher:type': 'regex',
      regex: pattern.source,
      value: str,
    };
  }
  return {
    'pact:matcher:type': 'regex',
    regex: pattern,
    value: str,
  };
}

/**
 * Value that must be equal to the example. This is mainly used to reset the matching rules which cascade.
 * @param value Example value
 */
export const equal = <T>(value: T): Matcher<T> => ({
  'pact:matcher:type': 'equality',
  value,
});

/**
 * String value that must match the provided datetime format string.
 * @param format Datetime format string. See [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html)
 * @param example Example value to use. If omitted a value using the current system date and time will be generated.
 */
export function datetime(format: string, example: string): DateTimeMatcher {
  if (!example) {
    throw new Error(`you must provide an example datetime`);
  }

  return pickBy((v) => !isNil(v), {
    'pact:generator:type': example ? undefined : 'DateTime',
    'pact:matcher:type': 'timestamp',
    format,
    value: example,
  });
}

/**
 * String value that must match the provided datetime format string.
 * @param format Datetime format string. See [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html)
 * @param example Example value to use. If omitted a value using the current system date and time will be generated.
 */
export function timestamp(format: string, example: string): DateTimeMatcher {
  if (!example) {
    throw new Error(`you must provide an example timestamp`);
  }
  return datetime(format, example);
}

/**
 * String value that must match the provided time format string.
 * @param format Time format string. See [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html)
 * @param example Example value to use. If omitted a value using the current system time will be generated.
 */
export function time(format: string, example: string): DateTimeMatcher {
  if (!example) {
    throw new Error(`you must provide an example time`);
  }
  return {
    'pact:generator:type': 'Time',
    'pact:matcher:type': 'time',
    format,
    value: example,
  };
}

/**
 * String value that must match the provided date format string.
 * @param format Date format string. See [Java SimpleDateFormat](https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html)
 * @param example Example value to use. If omitted a value using the current system date will be generated.
 */
export function date(format: string, example: string): DateTimeMatcher {
  if (!example) {
    throw new Error(`you must provide an example date`);
  }
  return {
    format,
    'pact:generator:type': 'Date',
    'pact:matcher:type': 'date',
    value: example,
  };
}

/**
 * Value that must include the example value as a substring.
 * @param value String value to include
 */
export function includes(value: string): Matcher<string> {
  return {
    'pact:matcher:type': 'include',
    value,
  };
}

/**
 * Value that must be null. This will only match the JSON Null value. For other content types, it will
 * match if the attribute is missing.
 */
export function nullValue(): Matcher<null> {
  return {
    'pact:matcher:type': 'null',
  };
}

function stringFromRegex(r: RegExp): string {
  return new RandExp(r).gen();
}

/**
 * Matches a URL composed of a base path and a list of path fragments
 * @param basePath Base path of the URL. If null, will use the base URL from the mock server.
 * @param pathFragments list of path fragments, can be regular expressions
 */
export function url2(
  basePath: string | null,
  pathFragments: Array<string | V3RegexMatcher | RegExp>
): V3RegexMatcher {
  const regexpr = [
    '.*(',
    ...pathFragments.map((p) => {
      if (p instanceof RegExp) {
        return `\\/${p.source}`;
      }
      if (p instanceof Object && p['pact:matcher:type'] === 'regex') {
        return `\\/${p.regex}`;
      }
      return `\\/${p.toString()}`;
    }),
  ].join('');

  const example = [
    basePath || 'http://localhost:8080',
    ...pathFragments.map((p) => {
      if (p instanceof RegExp) {
        return `/${stringFromRegex(p)}`;
      }
      if (p instanceof Object && p['pact:matcher:type'] === 'regex') {
        return `/${p.value}`;
      }
      return `/${p.toString()}`;
    }),
  ].join('');

  // Temporary fix for inconsistancies between matchers and generators. Matchers use "value" attribute for
  // example values, while generators use "example"
  if (basePath == null) {
    return {
      'pact:matcher:type': 'regex',
      'pact:generator:type': 'MockServerURL',
      regex: `${regexpr})$`,
      value: example,
      example,
    };
  }
  return {
    'pact:matcher:type': 'regex',
    regex: `${regexpr})$`,
    value: example,
  };
}

/**
 * Matches a URL composed of a list of path fragments. The base URL from the mock server will be used.
 * @param pathFragments list of path fragments, can be regular expressions
 */
export function url(
  pathFragments: Array<string | V3RegexMatcher | RegExp>
): V3RegexMatcher {
  return url2(null, pathFragments);
}

/**
 * Matches the items in an array against a number of variants. Matching is successful if each variant
 * occurs once in the array. Variants may be objects containing matching rules.
 */
export function arrayContaining(...variants: unknown[]): ArrayContainsMatcher {
  return {
    'pact:matcher:type': 'arrayContains',
    variants,
  };
}

/**
 * Marks an item to be injected from the provider state
 * @param expression Expression to lookup in the provider state context
 * @param exampleValue Example value to use in the consumer test
 */
export function fromProviderState<V>(
  expression: string,
  exampleValue: V
): ProviderStateInjectedValue<V> {
  return {
    'pact:matcher:type': 'type',
    'pact:generator:type': 'ProviderState',
    expression,
    value: exampleValue,
  };
}

/**
 * Match a universally unique identifier (UUID). Random values will be used for examples if no example is given.
 */
export function uuid(example?: string): V3RegexMatcher {
  const regexStr =
    '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
  if (example) {
    const regexpr = new RegExp(`^${regexStr}$`);
    if (!example.match(regexpr)) {
      throw new Error(
        `regex: Example value '${example}' does not match the UUID regular expression '${regexStr}'`
      );
    }
    return {
      'pact:matcher:type': 'regex',
      regex: regexStr,
      value: example,
    };
  }
  return {
    'pact:matcher:type': 'regex',
    regex: regexStr,
    'pact:generator:type': 'Uuid',
    value: 'e2490de5-5bd3-43d5-b7c4-526e33f71304',
  };
}

export const matcherValueOrString = (obj: unknown): string => {
  if (typeof obj === 'string') return obj;

  return JSON.stringify(obj);
};

/**
 * Recurse the object removing any underlying matching guff, returning the raw
 * example content.
 */
export function reify(input: unknown): AnyJson {
  if (isMatcher(input)) {
    return reify(input.value);
  }

  if (Array.isArray(input)) {
    return input.map(reify);
  }

  if (typeof input === 'object') {
    if (input === null) {
      return input;
    }
    return Object.keys(input).reduce(
      (acc: JsonMap, propName: keyof typeof input) => ({
        ...acc,
        [propName]: reify(input[propName]),
      }),
      {}
    );
  }

  if (
    typeof input === 'number' ||
    typeof input === 'string' ||
    typeof input === 'boolean'
  ) {
    return input;
  }
  throw new Error(
    `Unable to strip matcher from a '${typeof input}', as it is not valid in a Pact description`
  );
}

export { reify as extractPayload };