Skip to content

Commit

Permalink
feat(stub): add basic stub support
Browse files Browse the repository at this point in the history
  • Loading branch information
mefellows committed Dec 6, 2017
1 parent c1e20f7 commit 62185b5
Show file tree
Hide file tree
Showing 5 changed files with 320 additions and 4 deletions.
12 changes: 12 additions & 0 deletions bin/pact-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ cli
.option("--provider [providerName]", "Specify provider name for written Pact files.")
.action((args: any, options: any) => pact.createServer(options).start());

cli
.command("stub")
.description("Creates an API stub from pact files")
.option("-p, --port [n]", "Port on which to run the service. Default is random.", cli.INT)
.option("-h, --host [hostname]", "Host on which to bind the service. Default is localhost.")
.option("-l, --log [file]", "File to which to log output to.")
.option("-s, --ssl [boolean]", "Use a self-signed SSL cert to run the service over HTTPS. Default is false (HTTP).", cli.BOOL)
.option("-o, --cors [boolean]", "Support browser security in tests by responding to OPTIONS requests and adding CORS headers to mocked responses. Default is false.", cli.BOOL)
.option("-i, --pact-version [n]", "The Pact specification version to use when writing the Pact files. Default is 1.", cli.INT)
.option("-u, --pact-urls [URLs]", "Comma separated list of local Pact files", cli.LIST)
.action((args: any, options: any) => pact.createStub(options).start());

cli
.command("verify")
.description("Verifies Pact Contracts on the current provider")
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ export * from "./server";
export * from "./publisher";

export * from "./broker";

export * from "./stub";
41 changes: 40 additions & 1 deletion src/pact.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import q = require("q");
import serverFactory, {Server, ServerOptions} from "./server";
import stubFactory, {Stub, StubOptions} from "./stub";
import verifierFactory, {VerifierOptions} from "./verifier";
import publisherFactory, {PublisherOptions} from "./publisher";
import logger, {LogLevels} from "./logger";
import _ = require("underscore");

export class Pact {
private __servers: Server[] = [];
private __stubs: Stub[] = [];

constructor() {
// Listen for Node exiting or someone killing the process
Expand Down Expand Up @@ -45,7 +47,7 @@ export class Pact {
return this.__servers;
}

// Remove all the servers that"s been created
// Remove all the servers that have been created
// Return promise of all others
public removeAllServers(): q.Promise<Server[]> {
if(this.__servers.length === 0) {
Expand All @@ -56,6 +58,43 @@ export class Pact {
return q.all<Server>(_.map(this.__servers, (server:Server) => server.delete() as PromiseLike<Server>));
}

// Creates stub with specified options
public createStub(options: StubOptions = {}): Stub {
if (options && options.port && _.some(this.__stubs, (s: Stub) => s.options.port === options.port)) {
let msg = `Port '${options.port}' is already in use by another process.`;
logger.error(msg);
throw new Error(msg);
}

let stub = stubFactory(options);
this.__stubs.push(stub);
logger.info(`Creating Pact Stub with options: \n${this.__stringifyOptions(stub.options)}`);

// Listen to stub delete events, to remove from stub list
stub.once("delete", (s: Stub) => {
logger.info(`Deleting Pact Stub with options: \n${this.__stringifyOptions(s.options)}`);
this.__stubs = _.without(this.__stubs, s);
});

return stub;
}

// Return arrays of all stubs
public listStubs(): Stub[] {
return this.__stubs;
}

// Remove all the stubs that have been created
// Return promise of all others
public removeAllStubs(): q.Promise<Stub[]> {
if(this.__stubs.length === 0) {
return q(this.__stubs);
}

logger.info("Removing all Pact stubs.");
return q.all<Stub>(_.map(this.__stubs, (stub:Stub) => stub.delete() as PromiseLike<Stub>));
}

// Run the Pact Verification process
public verifyPacts(options: VerifierOptions): q.Promise<string> {
logger.info("Verifying Pacts.");
Expand Down
6 changes: 3 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@ export class Server extends events.EventEmitter {
}

public readonly options: ServerOptions;
private __running: boolean;
private __instance: ChildProcess;
private readonly __argMapping = {
protected readonly __argMapping = {
"port": "--port",
"host": "--host",
"log": "--log",
Expand All @@ -138,6 +136,8 @@ export class Server extends events.EventEmitter {
"consumer": "--consumer",
"provider": "--provider"
};
private __running: boolean;
private __instance: ChildProcess;

constructor(options: ServerOptions) {
super();
Expand Down
263 changes: 263 additions & 0 deletions src/stub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// tslint:disable:no-string-literal

import path = require("path");
import fs = require("fs");
import events = require("events");
import http = require("request");
import q = require("q");
import logger from "./logger";
import pactUtil, {DEFAULT_ARG, SpawnArguments} from "./pact-util";
import {ChildProcess} from "child_process";
const mkdirp = require("mkdirp");
const pact = require("@pact-foundation/pact-standalone");
const checkTypes = require("check-types");

const CHECKTIME = 500;
const RETRY_AMOUNT = 60;
const PROCESS_TIMEOUT = 30000;

export class Stub extends events.EventEmitter {
public static get Events() {
return {
START_EVENT: "start",
STOP_EVENT: "stop",
DELETE_EVENT: "delete"
};
}

public static create(options: StubOptions = {}): Stub {
// defaults
options.pactUrls = options.pactUrls || [];
options.ssl = options.ssl || false;
options.cors = options.cors || false;
options.host = options.host || "localhost";

if (options.pactUrls) {
checkTypes.assert.array.of.string(options.pactUrls);
}

checkTypes.assert.not.emptyArray(options.pactUrls, "Must provide the pactUrls argument");

// port checking
if (options.port) {
checkTypes.assert.number(options.port);
checkTypes.assert.integer(options.port);
checkTypes.assert.positive(options.port);
checkTypes.assert.inRange(options.port, 0, 65535);

if (checkTypes.not.inRange(options.port, 1024, 49151)) {
logger.warn("Like a Boss, you used a port outside of the recommended range (1024 to 49151); I too like to live dangerously.");
}
}

// ssl check
checkTypes.assert.boolean(options.ssl);

// Throw error if one ssl option is set, but not the other
if ((options.sslcert && !options.sslkey) || (!options.sslcert && options.sslkey)) {
throw new Error("Custom ssl certificate and key must be specified together.");
}

// check certs/keys exist for SSL
if (options.sslcert) {
try {
fs.statSync(path.normalize(options.sslcert)).isFile();
} catch (e) {
throw new Error(`Custom ssl certificate not found at path: ${options.sslcert}`);
}
}

if (options.sslkey) {
try {
fs.statSync(path.normalize(options.sslkey)).isFile();
} catch (e) {
throw new Error(`Custom ssl key not found at path: ${options.sslkey}`);
}
}

// If both sslcert and sslkey option has been specified, let's assume the user wants to enable ssl
if (options.sslcert && options.sslkey) {
options.ssl = true;
}

// cors check
checkTypes.assert.boolean(options.cors);

// log check
if (options.log) {
const fileObj = path.parse(path.normalize(options.log));
try {
fs.statSync(fileObj.dir).isDirectory();
} catch (e) {
// If log path doesn't exist, create it
mkdirp.sync(fileObj.dir);
}
}

// host check
if (options.host) {
checkTypes.assert.string(options.host);
}

return new Stub(options);
}

public readonly options: StubOptions;
private __running: boolean;
private __instance: ChildProcess;
private readonly __argMapping = {
"pactUrls": DEFAULT_ARG,
"port": "--port",
"host": "--host",
"log": "--log",
"ssl": "--ssl",
"sslcert": "--sslcert",
"sslkey": "--sslkey",
"cors": "--cors",
};

constructor(options: StubOptions) {
super();
this.options = options;
this.__running = false;
}

// Let the stubbing begin!
public start(): q.Promise<Stub> {
if (this.__instance && this.__instance.connected) {
logger.warn(`You already have a process running with PID: ${this.__instance.pid}`);
return q.resolve(this);
}
this.__instance = pactUtil.spawnBinary(`${pact.stubPath} service`, this.options, this.__argMapping);
this.__instance.once("close", () => this.stop());

if (!this.options.port) {
// if port isn't specified, listen for it when pact runs
const catchPort = (data: any) => {
const match = data.match(/port=([0-9]+)/);
if (match && match[1]) {
this.options.port = parseInt(match[1], 10);
this.__instance.stdout.removeListener("data", catchPort);
this.__instance.stderr.removeListener("data", catchPort);
logger.info(`Pact running on port ${this.options.port}`);
}
};

this.__instance.stdout.on("data", catchPort);
this.__instance.stderr.on("data", catchPort);
}

// check service is available
return this.__waitForStubUp()
.timeout(PROCESS_TIMEOUT, `Couldn't start Pact with PID: ${this.__instance.pid}`)
.then(() => {
this.__running = true;
this.emit(Stub.Events.START_EVENT, this);
return this;
});
}

// Stop the stub instance
public stop(): q.Promise<Stub> {
const pid = this.__instance ? this.__instance.pid : -1;
return q(pactUtil.killBinary(this.__instance))
.then(() => this.__waitForStubDown())
.timeout(PROCESS_TIMEOUT, `Couldn't stop Pact with PID '${pid}'`)
.then(() => {
this.__running = false;
this.emit(Stub.Events.STOP_EVENT, this);
return this;
});
}

// Deletes this stub instance and emit an event
public delete(): q.Promise<Stub> {
return this.stop().tap(() => this.emit(Stub.Events.DELETE_EVENT, this));
}

// Wait for pact-stub-service to be initialized and ready
private __waitForStubUp(): q.Promise<any> {
let amount = 0;
const deferred = q.defer();

const retry = () => {
if (amount >= RETRY_AMOUNT) {
deferred.reject(new Error("Pact startup failed; tried calling service 10 times with no result."));
}
setTimeout(check.bind(this), CHECKTIME);
};

const check = () => {
amount++;
if (this.options.port) {
this.__call(this.options).then(() => deferred.resolve(), retry.bind(this));
} else {
retry();
}
};

check(); // Check first time, start polling
return deferred.promise;
}

private __waitForStubDown(): q.Promise<any> {
let amount = 0;
const deferred = q.defer();

const check = () => {
amount++;
if (this.options.port) {
this.__call(this.options).then(() => {
if (amount >= RETRY_AMOUNT) {
deferred.reject(new Error("Pact stop failed; tried calling service 10 times with no result."));
return;
}
setTimeout(check, CHECKTIME);
}, () => deferred.resolve());
} else {
deferred.resolve();
}
};

check(); // Check first time, start polling
return deferred.promise;
}

private __call(options: StubOptions): q.Promise<any> {
const deferred = q.defer();
const config: any = {
uri: `http${options.ssl ? "s" : ""}://${options.host}:${options.port}`,
method: "GET",
headers: {
"X-Pact-Mock-Service": true,
"Content-Type": "application/json"
}
};

if (options.ssl) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
config.agentOptions = {};
config.agentOptions.rejectUnauthorized = false;
config.agentOptions.requestCert = false;
config.agentOptions.agent = false;
}

http(config, (err: any, res: any) => (!err && res.statusCode === 200) ? deferred.resolve() : deferred.reject(`HTTP Error: '${JSON.stringify(err ? err : res)}'`));

return deferred.promise;
}
}

// Creates a new instance of the pact stub with the specified option
export default Stub.create;

export interface StubOptions extends SpawnArguments {
pactUrls?: string[];
port?: number;
ssl?: boolean;
cors?: boolean;
host?: string;
sslcert?: string;
sslkey?: string;
log?: string;
}

0 comments on commit 62185b5

Please sign in to comment.