Skip to content

Commit

Permalink
feat: Implement header mocking functionality SO-227 (#314)
Browse files Browse the repository at this point in the history
* refactor: rename example to bodyExample since there's headers examples as well

* feat: add husky

* chore: git add as well

* fix: better error message

* feat: add headers to negotiated result

* test: update test to keep in count the headers

* feat: return headers from negotiation retults

* feat: use the headers to generate the examples where appropriate

* chore: fix package.json

* fix: check out if schema is defined

* test: make sure to include the generated header in tests

* test: add test for header mocking

* chore: set build target once

* chore: add header examples

* test: add header parsing type test

* chore: upgrade graphite

* feat: upgrade types graphite and refacto where appropriate

* chore: update graphite

* fix: arguments

* feat: prefer examples if provided

* test: correct the oas3 document so it's compliant

* chore: grammar nazi

Co-Authored-By: Phil Sturgeon <me@philsturgeon.uk>

* test: add case for empty headers

* chore: add dry command

* chore: readd typescript or oclif cries like a baby
  • Loading branch information
XVincentX committed May 24, 2019
1 parent 5cf1f99 commit 5f0c0ba
Show file tree
Hide file tree
Showing 28 changed files with 743 additions and 313 deletions.
8 changes: 7 additions & 1 deletion examples/petstore.oas2.json
Original file line number Diff line number Diff line change
Expand Up @@ -762,11 +762,17 @@
"format": "int32",
"description": "calls per hour allowed by the user"
},
"X-Stats": {
"type": "integer",
"format": "int32",
"description": "generic statistics"
},
"X-Expires-After": {
"type": "string",
"format": "date-time",
"description": "date in UTC when token expires"
}
},
"X-Strange-Header": {}
}
},
"400": {
Expand Down
16 changes: 16 additions & 0 deletions examples/petstore.oas3.json
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,19 @@
"schema": {
"type": "integer",
"format": "int32"
},
"example": 1000
},
"X-Stats": {
"description": "generic statistics",
"schema": {
"type": "integer",
"format": "int32"
},
"examples": {
"etucapio": {
"value": 1500
}
}
},
"X-Expires-After": {
Expand All @@ -729,6 +742,9 @@
"type": "string",
"format": "date-time"
}
},
"X-Strange-Header": {
"description": "date in UTC when token expires"
}
},
"content": {
Expand Down
17 changes: 15 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,18 @@
"release": "lerna version prerelease --conventional-commits --preid alpha"
},
"devDependencies": {
"@stoplight/types": "^7.0.1",
"@oclif/dev-cli": "^1.22.0",
"@oclif/tslint": "^3.1.1",
"@stoplight/types": "^5.1.1",
"@types/caseless": "^0.12.2",
"@types/chai": "^4.1.7",
"@types/chance": "^1.0.4",
"@types/jest": "^24.0.13",
"@types/json-schema": "^7.0.3",
"@types/lodash": "^4.14.132",
"@types/node": "^12.0.2",
"husky": "^2.3.0",
"lint-staged": "^8.1.7",
"@types/signale": "^1.2.1",
"chance": "^1.0.18",
"globby": "^9.2.0",
Expand All @@ -52,8 +54,19 @@
"singleQuote": true,
"trailingComma": "es5"
},
"lint-staged": {
"*.ts": [
"tslint -p packages/tsconfig.json --fix",
"git add"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"publishConfig": {
"tag": "beta"
"tag": "alpha"
},
"dependencies": {}
}
9 changes: 6 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,13 @@
"url": "https://github.com/stoplightio/prism.git"
},
"scripts": {
"prepack": "node -r tsconfig-paths/register ./node_modules/.bin/oclif-dev manifest",
"version": "node -r tsconfig-paths/register ./node_modules/.bin/oclif-dev readme && git add README.md",
"prepack": "node -r tsconfig-paths/register ../../node_modules/.bin/oclif-dev manifest",
"version": "node -r tsconfig-paths/register ../../node_modules/.bin/oclif-dev readme && git add README.md",
"cli": "node -r tsconfig-paths/register bin/run",
"cli:debug": "node --inspect-brk -r tsconfig-paths/register bin/run"
},
"types": "lib/index.d.ts"
"types": "lib/index.d.ts",
"devDependencies": {
"typescript": "^3.4.5"
}
}
5 changes: 3 additions & 2 deletions packages/cli/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "lib",
"target": "es2017",
"sourceMap": false,
},
"include": ["src"],
"include": [
"src"
],
"references": [
{
"path": "../http-server/tsconfig.build.json"
Expand Down
1 change: 0 additions & 1 deletion packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es2017",
"outDir": "lib",
"rootDirs": [
"src"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"node": ">=8"
},
"dependencies": {
"@stoplight/graphite": "7.1.1",
"@stoplight/graphite": "^7.1.3",
"axios": "^0.18.0",
"lodash": "^4.0.0",
"mobx": "^5.0.0",
Expand Down
21 changes: 21 additions & 0 deletions packages/http-server/src/__tests__/server.oas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,27 @@ describe.each([['petstore.oas2.json'], ['petstore.oas3.json']])('server %s', fil
expect(response.statusCode).toBe(500);
checkErrorPayloadShape(response.payload);
});

test('should mock the response headers', async () => {
const response = await server.fastify.inject({
method: 'GET',
url: '/user/login?username=foo&password=foo',
});

// OAS2 does not support examples for Headers, to they MUST be always generated automagically,
// accorging to the schema

const expectedValues = {
'x-rate-limit': file === 'petstore.oas3.json' ? 1000 : expect.any(String),
'x-stats': file === 'petstore.oas3.json' ? 1500 : expect.any(String),
'x-expires-after': expect.any(String),
'x-strange-header': file === 'petstore.oas3.json' ? 'string' : '{}',
};

for (const headerName of Object.keys(expectedValues)) {
expect(response.headers).toHaveProperty(headerName, expectedValues[headerName]);
}
});
});

describe('oas2 specific tests', () => {
Expand Down
39 changes: 15 additions & 24 deletions packages/http/src/__tests__/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,11 @@ export const httpOperations: IHttpOperation[] = [
{
name: 'x-todos-publish',
style: HttpParamStyles.Simple,
contents: [
{
mediaType: '*',
schema: { type: 'string', format: 'date-time' },
examples: [],
encodings: [],
},
],
content: {
schema: { type: 'string', format: 'date-time' },
examples: [],
encodings: [],
},
},
],
contents: [
Expand Down Expand Up @@ -264,28 +261,22 @@ export const httpOperations: IHttpOperation[] = [
{
name: 'overwrite',
style: HttpParamStyles.Form,
contents: [
{
mediaType: '*',
schema: { type: 'string', pattern: '^(yes|no)$' },
examples: [],
encodings: [],
},
],
content: {
schema: { type: 'string', pattern: '^(yes|no)$' },
examples: [],
encodings: [],
},
},
],
headers: [
{
name: 'x-todos-publish',
style: HttpParamStyles.Simple,
contents: [
{
mediaType: '*',
schema: { type: 'string', format: 'date-time' },
examples: [],
encodings: [],
},
],
content: {
schema: { type: 'string', format: 'date-time' },
examples: [],
encodings: [],
},
},
],
cookie: [],
Expand Down
62 changes: 51 additions & 11 deletions packages/http/src/mocker/HttpMocker.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { IMocker, IMockerOpts } from '@stoplight/prism-core';
import { IHttpOperation } from '@stoplight/types';
import { Dictionary, IHttpHeaderParam, IHttpOperation, INodeExample, INodeExternalExample } from '@stoplight/types';

import * as caseless from 'caseless';
import { fromPairs, keyBy, mapValues, toPairs } from 'lodash';
import { IHttpConfig, IHttpOperationConfig, IHttpRequest, IHttpResponse, ProblemJsonError } from '../types';
import { UNPROCESSABLE_ENTITY } from './errors';
import { IExampleGenerator } from './generator/IExampleGenerator';
import helpers from './negotiator/NegotiatorHelpers';
import { IHttpNegotiationResult } from './negotiator/types';

export class HttpMocker implements IMocker<IHttpOperation, IHttpRequest, IHttpConfig, IHttpResponse> {
constructor(private _exampleGenerator: IExampleGenerator) {}
Expand Down Expand Up @@ -35,7 +37,7 @@ export class HttpMocker implements IMocker<IHttpOperation, IHttpRequest, IHttpCo
}

// looking up proper example
let negotiationResult;
let negotiationResult: IHttpNegotiationResult;
if (input.validations.input.length > 0) {
try {
negotiationResult = helpers.negotiateOptionsForInvalidRequest(resource.responses);
Expand All @@ -49,22 +51,60 @@ export class HttpMocker implements IMocker<IHttpOperation, IHttpRequest, IHttpCo
negotiationResult = helpers.negotiateOptionsForValidRequest(resource, mockConfig);
}

// preparing response body
let body;
const example = negotiationResult.example;

if (example && 'value' in example && example.value !== undefined) {
body = typeof example.value === 'string' ? example.value : JSON.stringify(example.value);
} else if (negotiationResult.schema) {
body = await this._exampleGenerator.generate(negotiationResult.schema, negotiationResult.mediaType);
}
const [body, mockedHeaders] = await Promise.all([
computeBody(negotiationResult, this._exampleGenerator),
computeMockedHeaders(negotiationResult.headers, this._exampleGenerator),
]);

return {
statusCode: parseInt(negotiationResult.code),
headers: {
...mockedHeaders,
'Content-type': negotiationResult.mediaType,
},
body,
};
}
}

function isINodeExample(nodeExample: INodeExample | INodeExternalExample | undefined): nodeExample is INodeExample {
return !!nodeExample && 'value' in nodeExample;
}

function computeMockedHeaders(headers: IHttpHeaderParam[], ex: IExampleGenerator): Promise<Dictionary<string>> {
const headerWithPromiseValues = mapValues(keyBy(headers, h => h.name), async header => {
if (header.content) {
if (header.content.examples.length > 0) {
const example = header.content.examples[0];
if (isINodeExample(example)) {
return example.value;
}
}
if (header.content.schema) {
return ex.generate(header.content.schema, 'application/json');
}
}
return 'string';
});

return resolvePromiseInProps(headerWithPromiseValues);
}

async function computeBody(
negotiationResult: Pick<IHttpNegotiationResult, 'schema' | 'mediaType' | 'bodyExample'>,
ex: IExampleGenerator,
) {
if (isINodeExample(negotiationResult.bodyExample) && negotiationResult.bodyExample.value !== undefined) {
return typeof negotiationResult.bodyExample.value === 'string'
? negotiationResult.bodyExample.value
: JSON.stringify(negotiationResult.bodyExample.value);
} else if (negotiationResult.schema) {
return ex.generate(negotiationResult.schema, negotiationResult.mediaType);
}
return undefined;
}

async function resolvePromiseInProps(val: Dictionary<Promise<string>>): Promise<Dictionary<string>> {
const promisePair = await Promise.all(toPairs(val).map(v => Promise.all(v)));
return fromPairs(promisePair);
}
28 changes: 16 additions & 12 deletions packages/http/src/mocker/__tests__/HttpMocker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe('HttpMocker', () => {
it('returns an empty body when negotiator did not resolve to either example nor schema', () => {
jest
.spyOn(helpers, 'negotiateOptionsForValidRequest')
.mockImplementation(() => ({ code: '202', mediaType: 'test' }));
.mockReturnValue({ code: '202', mediaType: 'test', headers: [] });

return expect(
httpMocker.mock({
Expand All @@ -105,11 +105,12 @@ describe('HttpMocker', () => {
});

it('returns static example', () => {
jest.spyOn(helpers, 'negotiateOptionsForValidRequest').mockImplementation(() => ({
jest.spyOn(helpers, 'negotiateOptionsForValidRequest').mockReturnValue({
code: '202',
mediaType: 'test',
example: mockResource.responses![0].contents![0].examples![0],
}));
bodyExample: mockResource.responses![0].contents![0].examples![0],
headers: [],
});

return expect(
httpMocker.mock({
Expand All @@ -120,11 +121,12 @@ describe('HttpMocker', () => {
});

it('returns dynamic example', () => {
jest.spyOn(helpers, 'negotiateOptionsForValidRequest').mockImplementation(() => ({
jest.spyOn(helpers, 'negotiateOptionsForValidRequest').mockReturnValue({
code: '202',
mediaType: 'test',
schema: mockResource.responses![0].contents![0].schema,
}));
headers: [],
});

jest.spyOn(mockExampleGenerator, 'generate').mockResolvedValue('example value');

Expand All @@ -139,11 +141,12 @@ describe('HttpMocker', () => {

describe('with invalid negotiator response', () => {
it('returns static example', () => {
jest.spyOn(helpers, 'negotiateOptionsForInvalidRequest').mockImplementation(() => ({
jest.spyOn(helpers, 'negotiateOptionsForInvalidRequest').mockReturnValue({
code: '202',
mediaType: 'test',
example: mockResource.responses![0].contents![0].examples![0],
}));
bodyExample: mockResource.responses![0].contents![0].examples![0],
headers: [],
});

return expect(
httpMocker.mock({
Expand All @@ -156,12 +159,13 @@ describe('HttpMocker', () => {

describe('when example is of type INodeExternalExample', () => {
it('generates a dynamic example', () => {
jest.spyOn(helpers, 'negotiateOptionsForValidRequest').mockImplementation(() => ({
jest.spyOn(helpers, 'negotiateOptionsForValidRequest').mockReturnValue({
code: '202',
mediaType: 'test',
example: mockResource.responses![0].contents![0].examples![1],
bodyExample: mockResource.responses![0].contents![0].examples![1],
headers: [],
schema: { type: 'string' },
}));
});

jest.spyOn(mockExampleGenerator, 'generate').mockResolvedValue('example value chelsea');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Object {
"body": "<todo><name>Shopping</name><completed>false</completed></todo>",
"headers": Object {
"Content-type": "application/xml",
"x-todos-publish": Any<String>,
},
"statusCode": 200,
}
Expand Down
Loading

0 comments on commit 5f0c0ba

Please sign in to comment.