From acd7526ba2a49275faf60c71905993780ef5ae59 Mon Sep 17 00:00:00 2001 From: Brenda Rearden Date: Tue, 12 Mar 2024 15:47:45 -0700 Subject: [PATCH 1/8] wip --- .../src/mocker/__tests__/HttpMocker.spec.ts | 28 +++++++++++- packages/http/src/mocker/index.ts | 43 +++++++++++++------ 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/packages/http/src/mocker/__tests__/HttpMocker.spec.ts b/packages/http/src/mocker/__tests__/HttpMocker.spec.ts index 96c85e25c..fa66e3c66 100644 --- a/packages/http/src/mocker/__tests__/HttpMocker.spec.ts +++ b/packages/http/src/mocker/__tests__/HttpMocker.spec.ts @@ -292,7 +292,7 @@ describe('mocker', () => { right: { param1: 'test1', param2: 'test2', - } + }, }), }), }) @@ -313,6 +313,32 @@ describe('mocker', () => { input: Object.assign({}, mockInput, { validations: [{ severity: DiagnosticSeverity.Warning }] }), })(logger); + expect(helpers.negotiateOptionsForValidRequest).toHaveBeenCalled(); + expect(helpers.negotiateOptionsForInvalidRequest).not.toHaveBeenCalled(); + }); + it.only('returns static example from a string testing another thing', () => { + jest.spyOn(helpers, 'negotiateOptionsForInvalidRequest'); + jest.spyOn(helpers, 'negotiateOptionsForValidRequest'); + + mock({ + config: { dynamic: false }, + resource: `{ + "openapi": "3.0.0", + "paths": { + "/pet": { + "get": { + "responses": { + "200": { + "description": "test" + } + } + } + } + } + }`, + input: Object.assign({}, mockInput, { validations: [{ severity: DiagnosticSeverity.Warning }] }), + })(logger); + expect(helpers.negotiateOptionsForValidRequest).toHaveBeenCalled(); expect(helpers.negotiateOptionsForInvalidRequest).not.toHaveBeenCalled(); }); diff --git a/packages/http/src/mocker/index.ts b/packages/http/src/mocker/index.ts index 905e719e5..c792d1768 100644 --- a/packages/http/src/mocker/index.ts +++ b/packages/http/src/mocker/index.ts @@ -1,4 +1,4 @@ -import { IPrismComponents, IPrismDiagnostic, IPrismInput } from '@stoplight/prism-core'; +import { IPrismComponents, IPrismComponentsWithPromise, IPrismDiagnostic, IPrismInput } from '@stoplight/prism-core'; import { DiagnosticSeverity, Dictionary, @@ -12,14 +12,16 @@ import { import * as caseless from 'caseless'; import * as chalk from 'chalk'; import * as E from 'fp-ts/Either'; +import * as TE from 'fp-ts/TaskEither'; +import * as T from 'fp-ts/Task'; import * as Record from 'fp-ts/Record'; -import { pipe } from 'fp-ts/function'; +import { Lazy, pipe } from 'fp-ts/function'; import * as A from 'fp-ts/Array'; import { sequenceT } from 'fp-ts/Apply'; import * as R from 'fp-ts/Reader'; import * as O from 'fp-ts/Option'; import * as RE from 'fp-ts/ReaderEither'; -import { get, groupBy, isNumber, isString, keyBy, mapValues, partial, pick } from 'lodash'; +import { get, groupBy, isError, isNumber, isString, keyBy, mapValues, partial, pick } from 'lodash'; import { Logger } from 'pino'; import { is } from 'type-is'; import { @@ -30,6 +32,7 @@ import { IHttpResponse, PayloadGenerator, ProblemJsonError, + isIHttpOperation, } from '../types'; import withLogger from '../withLogger'; import { UNAUTHORIZED, UNPROCESSABLE_ENTITY, INVALID_CONTENT_TYPE, SCHEMA_TOO_COMPLEX } from './errors'; @@ -43,7 +46,7 @@ import { deserializeFormBody, findContentByMediaTypeOrFirst, splitUriParams, - parseMultipartFormDataParams + parseMultipartFormDataParams, } from '../validator/validators/body'; import { parseMIMEHeader } from '../validator/validators/headers'; import { NonEmptyArray } from 'fp-ts/NonEmptyArray'; @@ -52,14 +55,22 @@ export { resetGenerator as resetJSONSchemaGenerator } from './generator/JSONSche const eitherRecordSequence = Record.sequence(E.Applicative); const eitherSequence = sequenceT(E.Apply); -const mock: IPrismComponents['mock'] = ({ +const mock: IPrismComponents['mock'] = ({ resource, input, config, }) => { + let finalResource: IHttpOperation; + if (typeof resource === 'string') { + finalResource = { method: 200, path: '/test', responses: {}, id: 12223 } as unknown as IHttpOperation; + } else { + finalResource = resource; + } + + //do additional work to get specific operation for request const payloadGenerator: PayloadGenerator = config.dynamic - ? partial(generate, resource, resource['__bundle__']) - : partial(generateStatic, resource); + ? partial(generate, finalResource, resource['__bundle__']) + : partial(generateStatic, finalResource); return pipe( withLogger(logger => { @@ -73,8 +84,8 @@ const mock: IPrismComponents negotiateResponse(mockConfig, input, resource)), - R.chain(result => negotiateDeprecation(result, resource)), + R.chain(mockConfig => negotiateResponse(mockConfig, input, finalResource)), + R.chain(result => negotiateDeprecation(result, finalResource)), R.chain(result => assembleResponse(result, payloadGenerator, config.ignoreExamples ?? false)), R.chain( response => @@ -85,7 +96,7 @@ const mock: IPrismComponents runCallbacks({ resource, request: input.data, response })(logger)), + E.map(response => runCallbacks({ resource: finalResource, request: input.data, response })(logger)), E.chain(() => response) ) ) @@ -151,10 +162,12 @@ function parseBodyIfUrlEncoded(request: IHttpRequest, resource: IHttpOperation) O.chainNullableK(body => body.contents), O.getOrElse(() => [] as IMediaTypeContent[]) ); - + const requestBody = request.body as string; const encodedUriParams = pipe( - mediaType === "multipart/form-data" ? parseMultipartFormDataParams(requestBody, multipartBoundary) : splitUriParams(requestBody), + mediaType === 'multipart/form-data' + ? parseMultipartFormDataParams(requestBody, multipartBoundary) + : splitUriParams(requestBody), E.getOrElse>(() => ({} as Dictionary)) ); @@ -384,7 +397,11 @@ function computeBody( payloadGenerator: PayloadGenerator, ignoreExamples: boolean ): E.Either { - if (!ignoreExamples && isINodeExample(negotiationResult.bodyExample) && negotiationResult.bodyExample.value !== undefined) { + if ( + !ignoreExamples && + isINodeExample(negotiationResult.bodyExample) && + negotiationResult.bodyExample.value !== undefined + ) { return E.right(negotiationResult.bodyExample.value); } if (negotiationResult.schema) { From ab2059c0f0d8210aa857d8ae052bb930ee07c08e Mon Sep 17 00:00:00 2001 From: Ed Vinyard Date: Fri, 15 Mar 2024 16:31:03 -0500 Subject: [PATCH 2/8] STOP-243 add fake IncomingMessage class so we can reuse existing Prism code that assumes the request came in from a socket --- .../__tests__/fakeIncomingMessage.spec.ts | 84 +++++++++++++ .../http/src/utils/fakeIncomingMessage.ts | 112 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 packages/http/src/utils/__tests__/fakeIncomingMessage.spec.ts create mode 100644 packages/http/src/utils/fakeIncomingMessage.ts diff --git a/packages/http/src/utils/__tests__/fakeIncomingMessage.spec.ts b/packages/http/src/utils/__tests__/fakeIncomingMessage.spec.ts new file mode 100644 index 000000000..e6e6ed600 --- /dev/null +++ b/packages/http/src/utils/__tests__/fakeIncomingMessage.spec.ts @@ -0,0 +1,84 @@ +import { FakeIncomingMessage } from '../fakeIncomingMessage'; +import * as typeIs from 'type-is'; + +describe('FakeIncomingMessage', () => { + it('fools isType when there is a body', () => { + // Arrange + const body = toUtf8('{}'); + const fake = new FakeIncomingMessage({ + method: 'POST', + url: 'http://example.com/', + headers: { + 'Content-Type': 'application/json', + 'Content-Encoding': 'UTF-8', + 'Content-Length': body.length.toString(), + }, + }); + fake.write(body); + + // Act + const actual = typeIs(fake, ['application/json']); + + // Assert + expect(actual).toBeTruthy(); + }); + + it('fools isType when there is NO body', () => { + // Arrange + const fake = new FakeIncomingMessage({ + method: 'GET', + url: 'http://example.com/', + headers: { + Accept: 'application/json', + }, + }); + + // Act + const actual = typeIs(fake, ['application/json']); + + // Assert + expect(actual).toBeFalsy(); + }); + + it('fools hasBody when there is a body', () => { + // Arrange + const body = toUtf8('{}'); + const fake = new FakeIncomingMessage({ + method: 'POST', + url: 'http://example.com/', + headers: { + 'Content-Type': 'application/json', + 'Content-Encoding': 'UTF-8', + 'Content-Length': body.length.toString(), + }, + }); + fake.write(body); + + // Act + const actual = typeIs.hasBody(fake); + + // Assert + expect(actual).toBeTruthy(); + }); + + it('fools hasBody when there is NO body', () => { + // Arrange + const fake = new FakeIncomingMessage({ + method: 'GET', + url: 'http://example.com/', + headers: { + Accept: 'application/json', + }, + }); + + // Act + const actual = typeIs.hasBody(fake); + + // Assert + expect(actual).toBeFalsy(); + }); +}); + +function toUtf8(s: string): Uint8Array { + return new TextEncoder().encode(s); +} diff --git a/packages/http/src/utils/fakeIncomingMessage.ts b/packages/http/src/utils/fakeIncomingMessage.ts new file mode 100644 index 000000000..109cb6832 --- /dev/null +++ b/packages/http/src/utils/fakeIncomingMessage.ts @@ -0,0 +1,112 @@ +/* eslint-disable prettier/prettier */ +import { Transform, TransformCallback } from 'stream'; +import type { IncomingMessage } from 'http'; +import { Socket } from 'net'; + +export type Options = { + method?: string; + url?: string; + headers?: Record; + rawHeaders?: string[]; +}; + +const BODYLESS_METHODS = ['GET', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE']; + +/* + * A replacement for IncomingMessage when an HTTP request isn't comind directly + * from a socket. + * + * This class was inspired from https://github.com/diachedelic/mock-req. + */ +export class FakeIncomingMessage extends Transform implements IncomingMessage { + private _failError: unknown; + + /** GET, PUT, POST, DELETE, OPTIONS, HEAD, etc. */ + public method: string; + + public url: string; + public headers: {}; + public rawHeaders: string[] = []; + + constructor(options: Options) { + super(); + options = options || {}; + + Transform.call(this); + this.method = options.method?.toUpperCase() || 'GET'; + this.url = options.url || ''; + + // Set header names + this.headers = {}; + this.rawHeaders = []; + const headers = options.headers; + if (headers !== undefined && headers !== null) { + Object.keys(headers).forEach(key => { + let val = headers[key]; + + if (val !== undefined) { + if (typeof val !== 'string') { + val = String(val); + } + + this.headers[key.toLowerCase()] = val; + + // Yep, this is weird! See https://nodejs.org/api/http.html#messagerawheaders + this.rawHeaders.push(key); + this.rawHeaders.push(val); + } + }); + } + + if (BODYLESS_METHODS.includes(this.method)) { + this.end(); + } + } + + _transform(chunk: any, _encoding: string, callback: TransformCallback): void { + if (this._failError) { + this.emit('error', this._failError); + return; + } + + if (typeof chunk !== 'string' && !Buffer.isBuffer(chunk)) { + chunk = JSON.stringify(chunk); + } + + this.push(chunk); + callback(); + } + + _fail(error: unknown): void { + this._failError = error; + } + + // The remaining aspects of IncomingMessage are intentionally NOT implemented. + get aborted(): boolean { throw notImplemented(); } + set aborted(_: boolean) { throw notImplemented(); } + get httpVersion(): string { throw notImplemented(); } + set httpVersion(_: string) { throw notImplemented(); } + get httpVersionMajor(): number { throw notImplemented(); } + set httpVersionMajor(_: number) { throw notImplemented(); } + get httpVersionMinor(): number { throw notImplemented(); } + set httpVersionMinor(_: number) { throw notImplemented(); } + get complete(): boolean { throw notImplemented(); } + set complete(_: boolean) { throw notImplemented(); } + get connection(): Socket { throw notImplemented(); } + set connection(_: Socket) { throw notImplemented(); } + get socket(): Socket { throw notImplemented(); } + set socket(_: Socket) { throw notImplemented(); } + get trailers(): NodeJS.Dict { throw notImplemented(); } + set trailers(_: NodeJS.Dict) { throw notImplemented(); } + get rawTrailers(): string[] { throw notImplemented(); } + set rawTrailers(_: string[]) { throw notImplemented(); } + get statusCode(): number { throw notImplemented(); } + set statusCode(_: number) { throw notImplemented(); } + get statusMessage(): string { throw notImplemented(); } + set statusMessage(_: string) { throw notImplemented(); } + setTimeout(): this { throw notImplemented(); } +} + +function notImplemented(): Error { + return new Error('method not implemented'); +} From ff6a770a22c18f9446b039853d8b7a0c717baf82 Mon Sep 17 00:00:00 2001 From: Brenda Rearden Date: Wed, 20 Mar 2024 11:59:37 -0700 Subject: [PATCH 3/8] create callable prism instance --- .../src/commands/__tests__/commands.spec.ts | 2 +- packages/cli/src/util/createServer.ts | 4 +- packages/cli/src/util/runner.ts | 2 +- .../__tests__/body-params-validation.spec.ts | 106 ++++--- .../src/__tests__/server.oas.spec.ts | 56 ++-- .../src/__tests__/http-prism-instance.spec.ts | 2 +- .../src/__tests__/instance-with-spec.spec.ts | 295 ++++++++++++++++++ packages/http/src/index.ts | 1 + packages/http/src/instanceWithSpec.ts | 41 +++ .../__tests__/fakeIncomingMessage.spec.ts | 84 ----- .../src/utils}/__tests__/operations.spec.ts | 2 +- .../http/src/utils/fakeIncomingMessage.ts | 112 ------- .../{cli/src => http/src/utils}/operations.ts | 2 +- 13 files changed, 424 insertions(+), 285 deletions(-) create mode 100644 packages/http/src/__tests__/instance-with-spec.spec.ts create mode 100644 packages/http/src/instanceWithSpec.ts delete mode 100644 packages/http/src/utils/__tests__/fakeIncomingMessage.spec.ts rename packages/{cli/src/util => http/src/utils}/__tests__/operations.spec.ts (97%) delete mode 100644 packages/http/src/utils/fakeIncomingMessage.ts rename packages/{cli/src => http/src/utils}/operations.ts (97%) diff --git a/packages/cli/src/commands/__tests__/commands.spec.ts b/packages/cli/src/commands/__tests__/commands.spec.ts index 62e7dbb83..db8ce065f 100644 --- a/packages/cli/src/commands/__tests__/commands.spec.ts +++ b/packages/cli/src/commands/__tests__/commands.spec.ts @@ -1,4 +1,4 @@ -import * as operationUtils from '../../operations'; +import * as operationUtils from '@stoplight/prism-http'; import * as yargs from 'yargs'; import { createMultiProcessPrism, createSingleProcessPrism } from '../../util/createServer'; import mockCommand from '../mock'; diff --git a/packages/cli/src/util/createServer.ts b/packages/cli/src/util/createServer.ts index 808c8ec8c..7c8e65245 100644 --- a/packages/cli/src/util/createServer.ts +++ b/packages/cli/src/util/createServer.ts @@ -10,10 +10,10 @@ import * as signale from 'signale'; import * as split from 'split2'; import { PassThrough, Readable } from 'stream'; import { LOG_COLOR_MAP } from '../const/options'; +import { CreatePrism } from './runner'; +import { getHttpOperationsFromSpec } from '@stoplight/prism-http'; import { createExamplePath } from './paths'; import { attachTagsToParamsValues, transformPathParamsValues } from './colorizer'; -import { CreatePrism } from './runner'; -import { getHttpOperationsFromSpec } from '../operations'; import { configureExtensionsUserProvided } from '../extensions'; type PrismLogDescriptor = pino.LogDescriptor & { diff --git a/packages/cli/src/util/runner.ts b/packages/cli/src/util/runner.ts index bfedcbc1b..ca986df36 100644 --- a/packages/cli/src/util/runner.ts +++ b/packages/cli/src/util/runner.ts @@ -2,7 +2,7 @@ import type { IPrismHttpServer } from '@stoplight/prism-http-server/src/types'; import * as chokidar from 'chokidar'; import * as os from 'os'; import { CreateMockServerOptions } from './createServer'; -import { getHttpOperationsFromSpec } from '../operations'; +import { getHttpOperationsFromSpec } from '@stoplight/prism-http'; export type CreatePrism = (options: CreateMockServerOptions) => Promise; diff --git a/packages/http-server/src/__tests__/body-params-validation.spec.ts b/packages/http-server/src/__tests__/body-params-validation.spec.ts index 693e5ba57..8dd93b0d9 100644 --- a/packages/http-server/src/__tests__/body-params-validation.spec.ts +++ b/packages/http-server/src/__tests__/body-params-validation.spec.ts @@ -640,7 +640,7 @@ describe('body params validation', () => { }); describe('and size bigger than 10MB', () => { - test('returns 422', async () => { + test('returns 413', async () => { const response = await makeRequest('/json-body-required', { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -761,23 +761,22 @@ describe('body params validation', () => { }, user_profiles: { type: 'array', - items: - { - type: 'object', - properties: { - foo: { - type: 'string' - }, - data: { - type: 'boolean' - }, - num: { - type: 'integer' - } - }, - required: ['foo', 'data'] + items: { + type: 'object', + properties: { + foo: { + type: 'string', }, - } + data: { + type: 'boolean', + }, + num: { + type: 'integer', + }, + }, + required: ['foo', 'data'], + }, + }, }, required: ['arrays', 'user_profiles'], $schema: 'http://json-schema.org/draft-07/schema#', @@ -785,7 +784,7 @@ describe('body params validation', () => { examples: [], encodings: [ { property: 'arrays', style: HttpParamStyles.Form, allowReserved: true, explode: false }, - { property: 'user_profiles', style: HttpParamStyles.Form, allowReserved: true, explode: false } + { property: 'user_profiles', style: HttpParamStyles.Form, allowReserved: true, explode: false }, ], }, ], @@ -844,19 +843,19 @@ describe('body params validation', () => { type: 'object', properties: { status: { - type: 'string' + type: 'string', }, lines: { - type: 'string' + type: 'string', }, test_img_file: { - type: 'string' + type: 'string', }, test_json_file: { - type: 'string' + type: 'string', }, num: { - type: 'integer' + type: 'integer', }, arrays: { type: 'array', @@ -864,17 +863,16 @@ describe('body params validation', () => { }, user_profiles: { type: 'array', - items: - { - type: 'object', - properties: { - foo: { - type: 'integer' - } - }, - required: ['foo'] + items: { + type: 'object', + properties: { + foo: { + type: 'integer', }, - } + }, + required: ['foo'], + }, + }, }, required: ['status', 'arrays', 'user_profiles'], $schema: 'http://json-schema.org/draft-07/schema#', @@ -882,7 +880,7 @@ describe('body params validation', () => { examples: [], encodings: [ { property: 'arrays', style: HttpParamStyles.Form, allowReserved: true, explode: false }, - { property: 'user_profiles', style: HttpParamStyles.Form, allowReserved: true, explode: false } + { property: 'user_profiles', style: HttpParamStyles.Form, allowReserved: true, explode: false }, ], }, ], @@ -980,7 +978,8 @@ describe('body params validation', () => { test('returns 200', async () => { const params = new URLSearchParams({ arrays: 'a,b,c', - user_profiles: '{"foo":"value1","num ":1, "data":true}, {"foo":"value2","data":false, "test": " hello +"}', + user_profiles: + '{"foo":"value1","num ":1, "data":true}, {"foo":"value2","data":false, "test": " hello +"}', }); const response = await makeRequest('/application-x-www-form-urlencoded-complex-request-body', { @@ -996,7 +995,8 @@ describe('body params validation', () => { const params = new URLSearchParams({ arrays: 'a,b,c', // Note invalid JSON "foo:"value1" - user_profiles: '{"foo:"value1","num ":1, "data":true}, {"foo":"value2","data":false, "test": " hello +"}', + user_profiles: + '{"foo:"value1","num ":1, "data":true}, {"foo":"value2","data":false, "test": " hello +"}', }); const response = await makeRequest('/application-x-www-form-urlencoded-complex-request-body', { @@ -1007,10 +1007,10 @@ describe('body params validation', () => { expect(response.status).toBe(415); expect(response.json()).resolves.toMatchObject({ - detail: "Cannot deserialize JSON object array in form data request body. Make sure the array is in JSON", + detail: 'Cannot deserialize JSON object array in form data request body. Make sure the array is in JSON', status: 415, - title: "Invalid content type", - type: "https://stoplight.io/prism/errors#INVALID_CONTENT_TYPE", + title: 'Invalid content type', + type: 'https://stoplight.io/prism/errors#INVALID_CONTENT_TYPE', }); }); }); @@ -1019,20 +1019,23 @@ describe('body params validation', () => { let requestParams: Dictionary; beforeEach(() => { const formData = new FormData(); - formData.append("status", "--=\""); - formData.append("lines", "\r\n\r\n\s"); - formData.append("test_img_file", "@test_img.png"); - formData.append("test_json_file", "{ + describe('boundary string generated correctly', () => { test('returns 200', async () => { const response = await makeRequest('/multipart-form-data-body-required', requestParams); expect(response.status).toBe(200); @@ -1042,14 +1045,15 @@ describe('body params validation', () => { describe('missing generated boundary string due to content-type manually specified in the header', () => { test('returns 415 & error message', async () => { - requestParams['headers'] = { 'content-type':'multipart/form-data' }; + requestParams['headers'] = { 'content-type': 'multipart/form-data' }; const response = await makeRequest('/multipart-form-data-body-required', requestParams); expect(response.status).toBe(415); expect(response.json()).resolves.toMatchObject({ - detail: "Boundary parameter for multipart/form-data is not defined or generated in the request header. Try removing manually defined content-type from your request header if it exists.", + detail: + 'Boundary parameter for multipart/form-data is not defined or generated in the request header. Try removing manually defined content-type from your request header if it exists.', status: 415, - title: "Invalid content type", - type: "https://stoplight.io/prism/errors#INVALID_CONTENT_TYPE", + title: 'Invalid content type', + type: 'https://stoplight.io/prism/errors#INVALID_CONTENT_TYPE', }); }); }); diff --git a/packages/http-server/src/__tests__/server.oas.spec.ts b/packages/http-server/src/__tests__/server.oas.spec.ts index 9c554fa0c..b3655823f 100644 --- a/packages/http-server/src/__tests__/server.oas.spec.ts +++ b/packages/http-server/src/__tests__/server.oas.spec.ts @@ -1,5 +1,5 @@ import { createLogger } from '@stoplight/prism-core'; -import { getHttpOperationsFromSpec } from '@stoplight/prism-cli/src/operations'; +import { getHttpOperationsFromSpec } from '@stoplight/prism-http'; import { IHttpConfig, IHttpMockConfig } from '@stoplight/prism-http'; import { resolve } from 'path'; import { merge } from 'lodash'; @@ -162,42 +162,36 @@ describe('Ignore examples', () => { describe('when running the server with ignoreExamples to true', () => { describe('and there is no preference header sent', () => { - it('should return an example statically generated by Prism rather than json-schema-faker', - async () => { - const response = await makeRequest('/pets', { method: 'GET' }); - const payload = await response.json(); - expect(hasPropertiesOfType(payload, schema)).toBe(true); - expect(payload.name).toBe('doggie'); - } - ) + it('should return an example statically generated by Prism rather than json-schema-faker', async () => { + const response = await makeRequest('/pets', { method: 'GET' }); + const payload = await response.json(); + expect(hasPropertiesOfType(payload, schema)).toBe(true); + expect(payload.name).toBe('doggie'); + }); }); describe('and I send a request with Prefer header selecting a specific example', () => { - it('should return an example statically generated by Prism rather than json-schema-faker', - async () => { - const response = await makeRequest('/pets', { - method: 'GET', - headers: { prefer: 'example=invalid_dog' } - }); - const payload = await response.json(); - expect(hasPropertiesOfType(payload, schema)).toBe(true); - expect(payload.name).toBe('doggie'); - } - ) + it('should return an example statically generated by Prism rather than json-schema-faker', async () => { + const response = await makeRequest('/pets', { + method: 'GET', + headers: { prefer: 'example=invalid_dog' }, + }); + const payload = await response.json(); + expect(hasPropertiesOfType(payload, schema)).toBe(true); + expect(payload.name).toBe('doggie'); + }); }); describe('and I send a request with dyanamic set to True', () => { - it('should return an example dynamically generated by json-schema-faker, ignoring the ignoreExamples flag', - async () => { - const response = await makeRequest('/pets', { - method: 'GET', - headers: { prefer: 'dynamic=true' } - }); - const payload = await response.json(); - expect(hasPropertiesOfType(payload, schema)).toBe(true); - expect(payload.name).not.toBe('doggie'); - } - ) + it('should return an example dynamically generated by json-schema-faker, ignoring the ignoreExamples flag', async () => { + const response = await makeRequest('/pets', { + method: 'GET', + headers: { prefer: 'dynamic=true' }, + }); + const payload = await response.json(); + expect(hasPropertiesOfType(payload, schema)).toBe(true); + expect(payload.name).not.toBe('doggie'); + }); }); }); }); diff --git a/packages/http/src/__tests__/http-prism-instance.spec.ts b/packages/http/src/__tests__/http-prism-instance.spec.ts index 035ce8d98..39fba188d 100644 --- a/packages/http/src/__tests__/http-prism-instance.spec.ts +++ b/packages/http/src/__tests__/http-prism-instance.spec.ts @@ -4,7 +4,7 @@ import { Scope as NockScope } from 'nock'; import * as nock from 'nock'; import { basename, resolve } from 'path'; import { createInstance, IHttpProxyConfig, IHttpRequest, IHttpResponse, ProblemJsonError } from '../'; -import { getHttpOperationsFromSpec } from '@stoplight/prism-cli/src/operations'; +import { getHttpOperationsFromSpec } from '../'; import { UNPROCESSABLE_ENTITY } from '../mocker/errors'; import { NO_PATH_MATCHED_ERROR, NO_SERVER_MATCHED_ERROR } from '../router/errors'; import { assertResolvesRight, assertResolvesLeft } from '@stoplight/prism-core/src/__tests__/utils'; diff --git a/packages/http/src/__tests__/instance-with-spec.spec.ts b/packages/http/src/__tests__/instance-with-spec.spec.ts new file mode 100644 index 000000000..b67e3e263 --- /dev/null +++ b/packages/http/src/__tests__/instance-with-spec.spec.ts @@ -0,0 +1,295 @@ +import { createLogger, IPrismOutput } from '@stoplight/prism-core'; +import { basename, resolve } from 'path'; +import { IHttpRequest, IHttpResponse, ProblemJsonError } from '../'; +import { UNPROCESSABLE_ENTITY } from '../mocker/errors'; +import { NO_PATH_MATCHED_ERROR, NO_SERVER_MATCHED_ERROR } from '../router/errors'; +import { createAndCallPrismInstanceWithSpec } from '../instanceWithSpec'; +import { IHttpConfig } from 'http/dist'; + +const logger = createLogger('TEST', { enabled: false }); + +const fixturePath = (filename: string) => resolve(__dirname, 'fixtures', filename); +const noRefsPetstoreMinimalOas2Path = fixturePath('no-refs-petstore-minimal.oas2.json'); +const staticExamplesOas2Path = fixturePath('static-examples.oas2.json'); +const serverValidationOas2Path = fixturePath('server-validation.oas2.json'); +const serverValidationOas3Path = fixturePath('server-validation.oas3.json'); + +let config: IHttpConfig = { + validateRequest: true, + checkSecurity: true, + validateResponse: true, + mock: { dynamic: false }, + errors: false, + upstreamProxy: undefined, + isProxy: false, +}; + +describe('Http Client .request', () => { + describe.each` + specName | specPath + ${basename(serverValidationOas2Path)} | ${serverValidationOas2Path} + ${basename(serverValidationOas3Path)} | ${serverValidationOas3Path} + `('given spec $specName', ({ specPath }) => { + describe('baseUrl not set', () => { + it('ignores server validation and returns 200', async () => { + const prismRequest: IHttpRequest = { + method: 'get', + url: { + path: '/pet', + }, + }; + const result = (await createAndCallPrismInstanceWithSpec( + specPath, + config, + prismRequest, + logger + )) as unknown as IPrismOutput; + expect(result.output).toBeDefined(); + expect(result.output.statusCode).toBe(200); + }); + }); + describe('valid baseUrl set', () => { + it('validates server and returns 200', async () => { + const prismRequest: IHttpRequest = { + method: 'get', + url: { + path: '/pet', + baseUrl: 'http://example.com/api', + }, + }; + const result = (await createAndCallPrismInstanceWithSpec( + specPath, + config, + prismRequest, + logger + )) as unknown as IPrismOutput; + expect(result.output).toBeDefined(); + expect(result.output.statusCode).toBe(200); + }); + }); + + describe('invalid host of baseUrl set', () => { + it('resolves with an error', async () => { + const prismRequest: IHttpRequest = { + method: 'get', + url: { + path: '/pet', + baseUrl: 'http://acme.com/api', + }, + }; + const result = await createAndCallPrismInstanceWithSpec(specPath, config, prismRequest, logger); + const resultJson = JSON.parse(result as string); + const expectedError = ProblemJsonError.fromTemplate(NO_SERVER_MATCHED_ERROR); + expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + }); + }); + + describe('invalid host and basePath of baseUrl set', () => { + it('resolves with an error', async () => { + const prismRequest: IHttpRequest = { + method: 'get', + url: { + path: '/pet', + baseUrl: 'http://example.com/v1', + }, + }; + const result = await createAndCallPrismInstanceWithSpec(specPath, config, prismRequest, logger); + const resultJson = JSON.parse(result as string); + const expectedError = ProblemJsonError.fromTemplate(NO_SERVER_MATCHED_ERROR); + expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + }); + }); + + describe('mocking is off', () => { + const baseUrl = 'https://stoplight.io'; + + describe.each<[boolean, string]>([ + [false, 'will let the request go through'], + [true, 'fails the operation'], + ])('errors flag is %s', (errors, testText) => { + config = { + mock: { dynamic: false }, + checkSecurity: true, + validateRequest: true, + validateResponse: true, + errors, + upstream: new URL(baseUrl), + upstreamProxy: undefined, + isProxy: true, + }; + + describe('path is not valid', () => { + const request: IHttpRequest = { + method: 'get', + url: { + path: '/x-bet', + baseUrl, + }, + }; + + it(testText, async () => { + const result = await createAndCallPrismInstanceWithSpec(specPath, config, request, logger); + if (typeof result === 'string') { + const resultJson = JSON.parse(result); + const expectedError = ProblemJsonError.fromTemplate(NO_PATH_MATCHED_ERROR); + expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + } else { + expect(result.output).toBeDefined(); + expect(result.output.statusCode).toBe(200); + } + }); + }); + }); + }); + }); + + describe('given no-refs-petstore-minimal.oas2.json', () => { + config = { + checkSecurity: true, + validateRequest: true, + validateResponse: true, + mock: { dynamic: false }, + errors: false, + upstreamProxy: undefined, + isProxy: false, + }; + const specPath = noRefsPetstoreMinimalOas2Path; + describe('path is invalid', () => { + it('resolves with an error', async () => { + const request: IHttpRequest = { + method: 'get', + url: { + path: '/unknown-path', + }, + }; + const result = await createAndCallPrismInstanceWithSpec(specPath, config, request, logger); + const resultJson = JSON.parse(result as string); + const expectedError = ProblemJsonError.fromTemplate(NO_PATH_MATCHED_ERROR); + expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + }); + }); + + describe('when requesting GET /pet/findByStatus', () => { + it('with valid query params returns generated body', async () => { + const request: IHttpRequest = { + method: 'get', + url: { + path: '/pet/findByStatus', + query: { + status: ['available', 'pending'], + }, + }, + }; + const result = (await createAndCallPrismInstanceWithSpec( + specPath, + config, + request, + logger + )) as unknown as IPrismOutput; + expect(result).toHaveProperty('output.body'); + expect(typeof result.output.body).toBe('string'); + }); + + it('w/o required params throws a validation error', async () => { + const request: IHttpRequest = { + method: 'get', + url: { + path: '/pet/findByStatus', + }, + }; + const result = await createAndCallPrismInstanceWithSpec(specPath, config, request, logger); + const resultJson = JSON.parse(result as string); + const expectedError = ProblemJsonError.fromTemplate(UNPROCESSABLE_ENTITY); + expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + }); + + it('with valid body param then returns no validation issues', async () => { + const request: IHttpRequest = { + method: 'get', + url: { + path: '/pet/findByStatus', + query: { + status: ['available'], + }, + }, + body: { + id: 1, + status: 'placed', + complete: true, + }, + }; + const result = (await createAndCallPrismInstanceWithSpec( + specPath, + config, + request, + logger + )) as unknown as IPrismOutput; + expect(result.validations).toEqual({ + input: [], + output: [], + }); + }); + }); + }); + + describe('headers validation', () => { + it('validates the headers even if casing does not match', async () => { + const request: IHttpRequest = { + method: 'get', + url: { + path: '/pet/login', + }, + headers: { + aPi_keY: 'hello', + }, + }; + const result = (await createAndCallPrismInstanceWithSpec( + noRefsPetstoreMinimalOas2Path, + config, + request, + logger + )) as unknown as IPrismOutput; + expect(result).toBeDefined(); + expect(result.output).toHaveProperty('statusCode', 200); + }); + + it('returns an error if the the header is missing', async () => { + const request: IHttpRequest = { + method: 'get', + url: { + path: '/pet/login', + }, + }; + const result = await createAndCallPrismInstanceWithSpec(noRefsPetstoreMinimalOas2Path, config, request, logger); + const resultJson = JSON.parse(result as string); + const expectedError = ProblemJsonError.fromTemplate(UNPROCESSABLE_ENTITY); + expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + }); + }); + + it('returns stringified static example when one defined in spec', async () => { + config = { + mock: { dynamic: false }, + checkSecurity: true, + validateRequest: true, + validateResponse: true, + errors: false, + upstreamProxy: undefined, + isProxy: false, + }; + const request: IHttpRequest = { + method: 'get', + url: { + path: '/todos', + }, + }; + const result = (await createAndCallPrismInstanceWithSpec( + staticExamplesOas2Path, + config, + request, + logger + )) as unknown as IPrismOutput; + expect(result.output).toBeDefined(); + expect(result.output.body).toBeInstanceOf(Array); + }); +}); diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index fc1676559..f66771423 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -12,6 +12,7 @@ export * from './mocker/serializer/style'; export { generate as generateHttpParam } from './mocker/generator/HttpParamGenerator'; export { resetJSONSchemaGenerator } from './mocker'; import { IHttpConfig, IHttpResponse, IHttpRequest, PickRequired, PrismHttpComponents, IHttpProxyConfig } from './types'; +export { getHttpOperationsFromSpec } from './utils/operations'; export const createInstance = ( defaultConfig: IHttpConfig | IHttpProxyConfig, diff --git a/packages/http/src/instanceWithSpec.ts b/packages/http/src/instanceWithSpec.ts new file mode 100644 index 000000000..d5f34476d --- /dev/null +++ b/packages/http/src/instanceWithSpec.ts @@ -0,0 +1,41 @@ +import { createInstance } from './index'; +import { getHttpOperationsFromSpec } from './utils/operations'; +import { IHttpConfig, IHttpRequest, ProblemJsonError } from './types'; +import * as pino from 'pino'; +import { pipe } from 'fp-ts/function'; +import { isRight, isLeft } from 'fp-ts/lib/Either'; +import * as TE from 'fp-ts/TaskEither'; +import * as E from 'fp-ts/Either'; +import * as IOE from 'fp-ts/IOEither'; +import { Dictionary } from '@stoplight/types'; + +export async function createAndCallPrismInstanceWithSpec( + document: string | object, + options: IHttpConfig, + prismRequest: IHttpRequest, + logger: pino.Logger +) { + const operations = await getHttpOperationsFromSpec(document); + const prism = createInstance(options, { logger: logger.child({ name: 'PRISM INSTANCE' }) }); + const result = await pipe( + prism.request(prismRequest, operations), + TE.chainIOEitherK(response => { + return IOE.fromEither( + E.tryCatch(() => { + return response; + }, E.toError) + ); + }), + TE.mapLeft((e: Error & { status?: number; additional?: { headers?: Dictionary } }) => { + logger.error({ prismRequest }, `Unable to generate response: ${e}`); + return e.status || 500, JSON.stringify(ProblemJsonError.toProblemJson(e)); + }) + )(); + if (isRight(result)) { + return result.right; + } + if (isLeft(result)) { + return result.left; + } + return result; +} diff --git a/packages/http/src/utils/__tests__/fakeIncomingMessage.spec.ts b/packages/http/src/utils/__tests__/fakeIncomingMessage.spec.ts deleted file mode 100644 index e6e6ed600..000000000 --- a/packages/http/src/utils/__tests__/fakeIncomingMessage.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { FakeIncomingMessage } from '../fakeIncomingMessage'; -import * as typeIs from 'type-is'; - -describe('FakeIncomingMessage', () => { - it('fools isType when there is a body', () => { - // Arrange - const body = toUtf8('{}'); - const fake = new FakeIncomingMessage({ - method: 'POST', - url: 'http://example.com/', - headers: { - 'Content-Type': 'application/json', - 'Content-Encoding': 'UTF-8', - 'Content-Length': body.length.toString(), - }, - }); - fake.write(body); - - // Act - const actual = typeIs(fake, ['application/json']); - - // Assert - expect(actual).toBeTruthy(); - }); - - it('fools isType when there is NO body', () => { - // Arrange - const fake = new FakeIncomingMessage({ - method: 'GET', - url: 'http://example.com/', - headers: { - Accept: 'application/json', - }, - }); - - // Act - const actual = typeIs(fake, ['application/json']); - - // Assert - expect(actual).toBeFalsy(); - }); - - it('fools hasBody when there is a body', () => { - // Arrange - const body = toUtf8('{}'); - const fake = new FakeIncomingMessage({ - method: 'POST', - url: 'http://example.com/', - headers: { - 'Content-Type': 'application/json', - 'Content-Encoding': 'UTF-8', - 'Content-Length': body.length.toString(), - }, - }); - fake.write(body); - - // Act - const actual = typeIs.hasBody(fake); - - // Assert - expect(actual).toBeTruthy(); - }); - - it('fools hasBody when there is NO body', () => { - // Arrange - const fake = new FakeIncomingMessage({ - method: 'GET', - url: 'http://example.com/', - headers: { - Accept: 'application/json', - }, - }); - - // Act - const actual = typeIs.hasBody(fake); - - // Assert - expect(actual).toBeFalsy(); - }); -}); - -function toUtf8(s: string): Uint8Array { - return new TextEncoder().encode(s); -} diff --git a/packages/cli/src/util/__tests__/operations.spec.ts b/packages/http/src/utils/__tests__/operations.spec.ts similarity index 97% rename from packages/cli/src/util/__tests__/operations.spec.ts rename to packages/http/src/utils/__tests__/operations.spec.ts index 173091e15..ccdf75be3 100644 --- a/packages/cli/src/util/__tests__/operations.spec.ts +++ b/packages/http/src/utils/__tests__/operations.spec.ts @@ -1,4 +1,4 @@ -import { getHttpOperationsFromSpec } from '../../operations'; +import { getHttpOperationsFromSpec } from '../operations'; describe('getHttpOperationsFromSpec()', () => { describe('ref resolving fails', () => { diff --git a/packages/http/src/utils/fakeIncomingMessage.ts b/packages/http/src/utils/fakeIncomingMessage.ts deleted file mode 100644 index 109cb6832..000000000 --- a/packages/http/src/utils/fakeIncomingMessage.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-disable prettier/prettier */ -import { Transform, TransformCallback } from 'stream'; -import type { IncomingMessage } from 'http'; -import { Socket } from 'net'; - -export type Options = { - method?: string; - url?: string; - headers?: Record; - rawHeaders?: string[]; -}; - -const BODYLESS_METHODS = ['GET', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE']; - -/* - * A replacement for IncomingMessage when an HTTP request isn't comind directly - * from a socket. - * - * This class was inspired from https://github.com/diachedelic/mock-req. - */ -export class FakeIncomingMessage extends Transform implements IncomingMessage { - private _failError: unknown; - - /** GET, PUT, POST, DELETE, OPTIONS, HEAD, etc. */ - public method: string; - - public url: string; - public headers: {}; - public rawHeaders: string[] = []; - - constructor(options: Options) { - super(); - options = options || {}; - - Transform.call(this); - this.method = options.method?.toUpperCase() || 'GET'; - this.url = options.url || ''; - - // Set header names - this.headers = {}; - this.rawHeaders = []; - const headers = options.headers; - if (headers !== undefined && headers !== null) { - Object.keys(headers).forEach(key => { - let val = headers[key]; - - if (val !== undefined) { - if (typeof val !== 'string') { - val = String(val); - } - - this.headers[key.toLowerCase()] = val; - - // Yep, this is weird! See https://nodejs.org/api/http.html#messagerawheaders - this.rawHeaders.push(key); - this.rawHeaders.push(val); - } - }); - } - - if (BODYLESS_METHODS.includes(this.method)) { - this.end(); - } - } - - _transform(chunk: any, _encoding: string, callback: TransformCallback): void { - if (this._failError) { - this.emit('error', this._failError); - return; - } - - if (typeof chunk !== 'string' && !Buffer.isBuffer(chunk)) { - chunk = JSON.stringify(chunk); - } - - this.push(chunk); - callback(); - } - - _fail(error: unknown): void { - this._failError = error; - } - - // The remaining aspects of IncomingMessage are intentionally NOT implemented. - get aborted(): boolean { throw notImplemented(); } - set aborted(_: boolean) { throw notImplemented(); } - get httpVersion(): string { throw notImplemented(); } - set httpVersion(_: string) { throw notImplemented(); } - get httpVersionMajor(): number { throw notImplemented(); } - set httpVersionMajor(_: number) { throw notImplemented(); } - get httpVersionMinor(): number { throw notImplemented(); } - set httpVersionMinor(_: number) { throw notImplemented(); } - get complete(): boolean { throw notImplemented(); } - set complete(_: boolean) { throw notImplemented(); } - get connection(): Socket { throw notImplemented(); } - set connection(_: Socket) { throw notImplemented(); } - get socket(): Socket { throw notImplemented(); } - set socket(_: Socket) { throw notImplemented(); } - get trailers(): NodeJS.Dict { throw notImplemented(); } - set trailers(_: NodeJS.Dict) { throw notImplemented(); } - get rawTrailers(): string[] { throw notImplemented(); } - set rawTrailers(_: string[]) { throw notImplemented(); } - get statusCode(): number { throw notImplemented(); } - set statusCode(_: number) { throw notImplemented(); } - get statusMessage(): string { throw notImplemented(); } - set statusMessage(_: string) { throw notImplemented(); } - setTimeout(): this { throw notImplemented(); } -} - -function notImplemented(): Error { - return new Error('method not implemented'); -} diff --git a/packages/cli/src/operations.ts b/packages/http/src/utils/operations.ts similarity index 97% rename from packages/cli/src/operations.ts rename to packages/http/src/utils/operations.ts index 518b68266..255cf1483 100644 --- a/packages/cli/src/operations.ts +++ b/packages/http/src/utils/operations.ts @@ -12,7 +12,7 @@ import type { OpenAPIObject } from 'openapi3-ts'; import type { CollectionDefinition } from 'postman-collection'; export async function getHttpOperationsFromSpec(specFilePathOrObject: string | object): Promise { - const prismVersion = require('../package.json').version; + const prismVersion = require('../../package.json').version; const httpResolverOpts: HTTPResolverOptions = { headers: { 'User-Agent': `PrismMockServer/${prismVersion} (${os.type()} ${os.arch()} ${os.release()})`, From 004c6e9bb15e89b2b367c40cb991b5ac21ea52dd Mon Sep 17 00:00:00 2001 From: Brenda Rearden Date: Wed, 20 Mar 2024 12:07:04 -0700 Subject: [PATCH 4/8] remove mock changes --- .../src/mocker/__tests__/HttpMocker.spec.ts | 26 ----------------- packages/http/src/mocker/index.ts | 29 ++++++------------- 2 files changed, 9 insertions(+), 46 deletions(-) diff --git a/packages/http/src/mocker/__tests__/HttpMocker.spec.ts b/packages/http/src/mocker/__tests__/HttpMocker.spec.ts index fa66e3c66..9ad103788 100644 --- a/packages/http/src/mocker/__tests__/HttpMocker.spec.ts +++ b/packages/http/src/mocker/__tests__/HttpMocker.spec.ts @@ -313,32 +313,6 @@ describe('mocker', () => { input: Object.assign({}, mockInput, { validations: [{ severity: DiagnosticSeverity.Warning }] }), })(logger); - expect(helpers.negotiateOptionsForValidRequest).toHaveBeenCalled(); - expect(helpers.negotiateOptionsForInvalidRequest).not.toHaveBeenCalled(); - }); - it.only('returns static example from a string testing another thing', () => { - jest.spyOn(helpers, 'negotiateOptionsForInvalidRequest'); - jest.spyOn(helpers, 'negotiateOptionsForValidRequest'); - - mock({ - config: { dynamic: false }, - resource: `{ - "openapi": "3.0.0", - "paths": { - "/pet": { - "get": { - "responses": { - "200": { - "description": "test" - } - } - } - } - } - }`, - input: Object.assign({}, mockInput, { validations: [{ severity: DiagnosticSeverity.Warning }] }), - })(logger); - expect(helpers.negotiateOptionsForValidRequest).toHaveBeenCalled(); expect(helpers.negotiateOptionsForInvalidRequest).not.toHaveBeenCalled(); }); diff --git a/packages/http/src/mocker/index.ts b/packages/http/src/mocker/index.ts index c792d1768..ff24e2a3f 100644 --- a/packages/http/src/mocker/index.ts +++ b/packages/http/src/mocker/index.ts @@ -1,4 +1,4 @@ -import { IPrismComponents, IPrismComponentsWithPromise, IPrismDiagnostic, IPrismInput } from '@stoplight/prism-core'; +import { IPrismComponents, IPrismDiagnostic, IPrismInput } from '@stoplight/prism-core'; import { DiagnosticSeverity, Dictionary, @@ -12,16 +12,14 @@ import { import * as caseless from 'caseless'; import * as chalk from 'chalk'; import * as E from 'fp-ts/Either'; -import * as TE from 'fp-ts/TaskEither'; -import * as T from 'fp-ts/Task'; import * as Record from 'fp-ts/Record'; -import { Lazy, pipe } from 'fp-ts/function'; +import { pipe } from 'fp-ts/function'; import * as A from 'fp-ts/Array'; import { sequenceT } from 'fp-ts/Apply'; import * as R from 'fp-ts/Reader'; import * as O from 'fp-ts/Option'; import * as RE from 'fp-ts/ReaderEither'; -import { get, groupBy, isError, isNumber, isString, keyBy, mapValues, partial, pick } from 'lodash'; +import { get, groupBy, isNumber, isString, keyBy, mapValues, partial, pick } from 'lodash'; import { Logger } from 'pino'; import { is } from 'type-is'; import { @@ -32,7 +30,6 @@ import { IHttpResponse, PayloadGenerator, ProblemJsonError, - isIHttpOperation, } from '../types'; import withLogger from '../withLogger'; import { UNAUTHORIZED, UNPROCESSABLE_ENTITY, INVALID_CONTENT_TYPE, SCHEMA_TOO_COMPLEX } from './errors'; @@ -55,22 +52,14 @@ export { resetGenerator as resetJSONSchemaGenerator } from './generator/JSONSche const eitherRecordSequence = Record.sequence(E.Applicative); const eitherSequence = sequenceT(E.Apply); -const mock: IPrismComponents['mock'] = ({ +const mock: IPrismComponents['mock'] = ({ resource, input, config, }) => { - let finalResource: IHttpOperation; - if (typeof resource === 'string') { - finalResource = { method: 200, path: '/test', responses: {}, id: 12223 } as unknown as IHttpOperation; - } else { - finalResource = resource; - } - - //do additional work to get specific operation for request const payloadGenerator: PayloadGenerator = config.dynamic - ? partial(generate, finalResource, resource['__bundle__']) - : partial(generateStatic, finalResource); + ? partial(generate, resource, resource['__bundle__']) + : partial(generateStatic, resource); return pipe( withLogger(logger => { @@ -84,8 +73,8 @@ const mock: IPrismComponents negotiateResponse(mockConfig, input, finalResource)), - R.chain(result => negotiateDeprecation(result, finalResource)), + R.chain(mockConfig => negotiateResponse(mockConfig, input, resource)), + R.chain(result => negotiateDeprecation(result, resource)), R.chain(result => assembleResponse(result, payloadGenerator, config.ignoreExamples ?? false)), R.chain( response => @@ -96,7 +85,7 @@ const mock: IPrismComponents runCallbacks({ resource: finalResource, request: input.data, response })(logger)), + E.map(response => runCallbacks({ resource, request: input.data, response })(logger)), E.chain(() => response) ) ) From f6621c3cafc3879af22f1ff50d497c0001f73148 Mon Sep 17 00:00:00 2001 From: Brenda Rearden Date: Wed, 20 Mar 2024 13:06:17 -0700 Subject: [PATCH 5/8] fix import --- packages/http/src/__tests__/instance-with-spec.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http/src/__tests__/instance-with-spec.spec.ts b/packages/http/src/__tests__/instance-with-spec.spec.ts index b67e3e263..e81b96aa1 100644 --- a/packages/http/src/__tests__/instance-with-spec.spec.ts +++ b/packages/http/src/__tests__/instance-with-spec.spec.ts @@ -4,7 +4,7 @@ import { IHttpRequest, IHttpResponse, ProblemJsonError } from '../'; import { UNPROCESSABLE_ENTITY } from '../mocker/errors'; import { NO_PATH_MATCHED_ERROR, NO_SERVER_MATCHED_ERROR } from '../router/errors'; import { createAndCallPrismInstanceWithSpec } from '../instanceWithSpec'; -import { IHttpConfig } from 'http/dist'; +import { IHttpConfig } from '../types'; const logger = createLogger('TEST', { enabled: false }); From 4051d468e4cc8ec1698bd695969ce8b72d4eb7fe Mon Sep 17 00:00:00 2001 From: Brenda Rearden Date: Thu, 21 Mar 2024 13:51:45 -0700 Subject: [PATCH 6/8] refactor to resolve pr review; add operations export; update tests; --- packages/cli/src/operations.ts | 2 + .../src/__tests__/instance-with-spec.spec.ts | 149 +++++------------- packages/http/src/instanceWithSpec.ts | 52 +++--- 3 files changed, 68 insertions(+), 135 deletions(-) create mode 100644 packages/cli/src/operations.ts diff --git a/packages/cli/src/operations.ts b/packages/cli/src/operations.ts new file mode 100644 index 000000000..3bf437683 --- /dev/null +++ b/packages/cli/src/operations.ts @@ -0,0 +1,2 @@ +// add to keep this from being a breaking change. +export { getHttpOperationsFromSpec } from '@stoplight/prism-http'; diff --git a/packages/http/src/__tests__/instance-with-spec.spec.ts b/packages/http/src/__tests__/instance-with-spec.spec.ts index e81b96aa1..2c1ca76ce 100644 --- a/packages/http/src/__tests__/instance-with-spec.spec.ts +++ b/packages/http/src/__tests__/instance-with-spec.spec.ts @@ -1,9 +1,9 @@ -import { createLogger, IPrismOutput } from '@stoplight/prism-core'; +import { createLogger } from '@stoplight/prism-core'; import { basename, resolve } from 'path'; -import { IHttpRequest, IHttpResponse, ProblemJsonError } from '../'; +import { IHttpRequest, ProblemJsonError } from '../'; import { UNPROCESSABLE_ENTITY } from '../mocker/errors'; import { NO_PATH_MATCHED_ERROR, NO_SERVER_MATCHED_ERROR } from '../router/errors'; -import { createAndCallPrismInstanceWithSpec } from '../instanceWithSpec'; +import { createAndCallPrismInstanceWithSpec, PrismErrorResult, PrismOkResult } from '../instanceWithSpec'; import { IHttpConfig } from '../types'; const logger = createLogger('TEST', { enabled: false }); @@ -38,14 +38,11 @@ describe('Http Client .request', () => { path: '/pet', }, }; - const result = (await createAndCallPrismInstanceWithSpec( - specPath, - config, - prismRequest, - logger - )) as unknown as IPrismOutput; - expect(result.output).toBeDefined(); - expect(result.output.statusCode).toBe(200); + const result = await createAndCallPrismInstanceWithSpec(specPath, config, prismRequest, logger); + expect(result.result).toBe('ok'); + const output = (result as PrismOkResult).response.output; + expect(output).toBeDefined(); + expect(output.statusCode).toBe(200); }); }); describe('valid baseUrl set', () => { @@ -57,14 +54,11 @@ describe('Http Client .request', () => { baseUrl: 'http://example.com/api', }, }; - const result = (await createAndCallPrismInstanceWithSpec( - specPath, - config, - prismRequest, - logger - )) as unknown as IPrismOutput; - expect(result.output).toBeDefined(); - expect(result.output.statusCode).toBe(200); + const result = await createAndCallPrismInstanceWithSpec(specPath, config, prismRequest, logger); + expect(result.result).toBe('ok'); + const output = (result as PrismOkResult).response.output; + expect(output).toBeDefined(); + expect(output.statusCode).toBe(200); }); }); @@ -78,9 +72,10 @@ describe('Http Client .request', () => { }, }; const result = await createAndCallPrismInstanceWithSpec(specPath, config, prismRequest, logger); - const resultJson = JSON.parse(result as string); - const expectedError = ProblemJsonError.fromTemplate(NO_SERVER_MATCHED_ERROR); - expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + expect(result.result).toBe('error'); + expect((result as PrismErrorResult).error).toMatchObject( + ProblemJsonError.fromTemplate(NO_SERVER_MATCHED_ERROR) + ); }); }); @@ -94,51 +89,10 @@ describe('Http Client .request', () => { }, }; const result = await createAndCallPrismInstanceWithSpec(specPath, config, prismRequest, logger); - const resultJson = JSON.parse(result as string); - const expectedError = ProblemJsonError.fromTemplate(NO_SERVER_MATCHED_ERROR); - expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); - }); - }); - - describe('mocking is off', () => { - const baseUrl = 'https://stoplight.io'; - - describe.each<[boolean, string]>([ - [false, 'will let the request go through'], - [true, 'fails the operation'], - ])('errors flag is %s', (errors, testText) => { - config = { - mock: { dynamic: false }, - checkSecurity: true, - validateRequest: true, - validateResponse: true, - errors, - upstream: new URL(baseUrl), - upstreamProxy: undefined, - isProxy: true, - }; - - describe('path is not valid', () => { - const request: IHttpRequest = { - method: 'get', - url: { - path: '/x-bet', - baseUrl, - }, - }; - - it(testText, async () => { - const result = await createAndCallPrismInstanceWithSpec(specPath, config, request, logger); - if (typeof result === 'string') { - const resultJson = JSON.parse(result); - const expectedError = ProblemJsonError.fromTemplate(NO_PATH_MATCHED_ERROR); - expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); - } else { - expect(result.output).toBeDefined(); - expect(result.output.statusCode).toBe(200); - } - }); - }); + expect(result.result).toBe('error'); + expect((result as PrismErrorResult).error).toMatchObject( + ProblemJsonError.fromTemplate(NO_SERVER_MATCHED_ERROR) + ); }); }); }); @@ -163,9 +117,8 @@ describe('Http Client .request', () => { }, }; const result = await createAndCallPrismInstanceWithSpec(specPath, config, request, logger); - const resultJson = JSON.parse(result as string); - const expectedError = ProblemJsonError.fromTemplate(NO_PATH_MATCHED_ERROR); - expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + expect(result.result).toBe('error'); + expect((result as PrismErrorResult).error).toMatchObject(ProblemJsonError.fromTemplate(NO_PATH_MATCHED_ERROR)); }); }); @@ -180,14 +133,11 @@ describe('Http Client .request', () => { }, }, }; - const result = (await createAndCallPrismInstanceWithSpec( - specPath, - config, - request, - logger - )) as unknown as IPrismOutput; - expect(result).toHaveProperty('output.body'); - expect(typeof result.output.body).toBe('string'); + const result = await createAndCallPrismInstanceWithSpec(specPath, config, request, logger); + expect(result.result).toBe('ok'); + const response = (result as PrismOkResult).response; + expect(response).toHaveProperty('output.body'); + expect(typeof response.output.body).toBe('string'); }); it('w/o required params throws a validation error', async () => { @@ -198,9 +148,8 @@ describe('Http Client .request', () => { }, }; const result = await createAndCallPrismInstanceWithSpec(specPath, config, request, logger); - const resultJson = JSON.parse(result as string); - const expectedError = ProblemJsonError.fromTemplate(UNPROCESSABLE_ENTITY); - expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + expect(result.result).toBe('error'); + expect((result as PrismErrorResult).error).toMatchObject(ProblemJsonError.fromTemplate(UNPROCESSABLE_ENTITY)); }); it('with valid body param then returns no validation issues', async () => { @@ -218,13 +167,9 @@ describe('Http Client .request', () => { complete: true, }, }; - const result = (await createAndCallPrismInstanceWithSpec( - specPath, - config, - request, - logger - )) as unknown as IPrismOutput; - expect(result.validations).toEqual({ + const result = await createAndCallPrismInstanceWithSpec(specPath, config, request, logger); + expect(result.result).toBe('ok'); + expect((result as PrismOkResult).response.validations).toEqual({ input: [], output: [], }); @@ -243,14 +188,10 @@ describe('Http Client .request', () => { aPi_keY: 'hello', }, }; - const result = (await createAndCallPrismInstanceWithSpec( - noRefsPetstoreMinimalOas2Path, - config, - request, - logger - )) as unknown as IPrismOutput; + const result = await createAndCallPrismInstanceWithSpec(noRefsPetstoreMinimalOas2Path, config, request, logger); expect(result).toBeDefined(); - expect(result.output).toHaveProperty('statusCode', 200); + expect(result.result).toBe('ok'); + expect((result as PrismOkResult).response.output).toHaveProperty('statusCode', 200); }); it('returns an error if the the header is missing', async () => { @@ -261,9 +202,8 @@ describe('Http Client .request', () => { }, }; const result = await createAndCallPrismInstanceWithSpec(noRefsPetstoreMinimalOas2Path, config, request, logger); - const resultJson = JSON.parse(result as string); - const expectedError = ProblemJsonError.fromTemplate(UNPROCESSABLE_ENTITY); - expect(ProblemJsonError.fromTemplate(resultJson)).toMatchObject(expectedError); + expect(result.result).toBe('error'); + expect((result as PrismErrorResult).error).toMatchObject(ProblemJsonError.fromTemplate(UNPROCESSABLE_ENTITY)); }); }); @@ -283,13 +223,10 @@ describe('Http Client .request', () => { path: '/todos', }, }; - const result = (await createAndCallPrismInstanceWithSpec( - staticExamplesOas2Path, - config, - request, - logger - )) as unknown as IPrismOutput; - expect(result.output).toBeDefined(); - expect(result.output.body).toBeInstanceOf(Array); + const result = await createAndCallPrismInstanceWithSpec(staticExamplesOas2Path, config, request, logger); + expect(result.result).toBe('ok'); + const output = (result as PrismOkResult).response.output; + expect(output).toBeDefined(); + expect(output.body).toBeInstanceOf(Array); }); }); diff --git a/packages/http/src/instanceWithSpec.ts b/packages/http/src/instanceWithSpec.ts index d5f34476d..4ddec5e56 100644 --- a/packages/http/src/instanceWithSpec.ts +++ b/packages/http/src/instanceWithSpec.ts @@ -1,41 +1,35 @@ import { createInstance } from './index'; import { getHttpOperationsFromSpec } from './utils/operations'; -import { IHttpConfig, IHttpRequest, ProblemJsonError } from './types'; -import * as pino from 'pino'; +import { IHttpConfig, IHttpRequest, IHttpResponse } from './types'; +import type { Logger } from 'pino'; import { pipe } from 'fp-ts/function'; import { isRight, isLeft } from 'fp-ts/lib/Either'; -import * as TE from 'fp-ts/TaskEither'; -import * as E from 'fp-ts/Either'; -import * as IOE from 'fp-ts/IOEither'; -import { Dictionary } from '@stoplight/types'; +import { IPrismOutput } from '@stoplight/prism-core'; + +export type PrismOkResult = { + result: 'ok'; + response: IPrismOutput; +}; + +export type PrismErrorResult = { + result: 'error'; + error: Error; +}; export async function createAndCallPrismInstanceWithSpec( - document: string | object, + spec: string | object, options: IHttpConfig, - prismRequest: IHttpRequest, - logger: pino.Logger -) { - const operations = await getHttpOperationsFromSpec(document); - const prism = createInstance(options, { logger: logger.child({ name: 'PRISM INSTANCE' }) }); - const result = await pipe( - prism.request(prismRequest, operations), - TE.chainIOEitherK(response => { - return IOE.fromEither( - E.tryCatch(() => { - return response; - }, E.toError) - ); - }), - TE.mapLeft((e: Error & { status?: number; additional?: { headers?: Dictionary } }) => { - logger.error({ prismRequest }, `Unable to generate response: ${e}`); - return e.status || 500, JSON.stringify(ProblemJsonError.toProblemJson(e)); - }) - )(); + request: IHttpRequest, + logger: Logger +): Promise { + const operations = await getHttpOperationsFromSpec(spec); + const prism = createInstance(options, { logger }); + const result = await pipe(prism.request(request, operations))(); if (isRight(result)) { - return result.right; + return { result: 'ok', response: result.right }; } if (isLeft(result)) { - return result.left; + return { result: 'error', error: result.left }; } - return result; + throw new Error('Unexpected Result'); } From fb17e3e1f574df65eaff2285fe4e74687ed92036 Mon Sep 17 00:00:00 2001 From: Brenda Rearden Date: Thu, 21 Mar 2024 14:14:38 -0700 Subject: [PATCH 7/8] add export for new function and types --- packages/http/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index f66771423..f151c559a 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -13,6 +13,7 @@ export { generate as generateHttpParam } from './mocker/generator/HttpParamGener export { resetJSONSchemaGenerator } from './mocker'; import { IHttpConfig, IHttpResponse, IHttpRequest, PickRequired, PrismHttpComponents, IHttpProxyConfig } from './types'; export { getHttpOperationsFromSpec } from './utils/operations'; +export { createAndCallPrismInstanceWithSpec, PrismErrorResult, PrismOkResult } from './instanceWithSpec'; export const createInstance = ( defaultConfig: IHttpConfig | IHttpProxyConfig, From 794b321802e0ec7fb378e3953251d560533dbe62 Mon Sep 17 00:00:00 2001 From: Brenda Rearden Date: Thu, 21 Mar 2024 15:00:38 -0700 Subject: [PATCH 8/8] update dependencies --- packages/cli/package.json | 1 - packages/http/package.json | 2 ++ yarn.lock | 18 +++++++++--------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index eddd90d82..32ab83497 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -7,7 +7,6 @@ }, "bugs": "https://github.com/stoplightio/prism/issues", "dependencies": { - "@stoplight/http-spec": "^7.0.2", "@stoplight/json": "^3.18.1", "@stoplight/json-schema-ref-parser": "9.2.7", "@stoplight/prism-core": "^5.6.0", diff --git a/packages/http/package.json b/packages/http/package.json index c0413b948..4c4ac3eab 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -21,6 +21,8 @@ "@stoplight/json-schema-merge-allof": "0.7.8", "@stoplight/json-schema-sampler": "0.3.0", "@stoplight/prism-core": "^5.6.0", + "@stoplight/http-spec": "^7.0.3", + "@stoplight/json-schema-ref-parser": "9.2.7", "@stoplight/types": "^14.1.0", "@stoplight/yaml": "^4.2.3", "abstract-logging": "^2.0.1", diff --git a/yarn.lock b/yarn.lock index 718a02bd9..eeff2a8cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1038,10 +1038,10 @@ dependencies: "@sinonjs/commons" "^3.0.0" -"@stoplight/http-spec@^7.0.2": - version "7.0.2" - resolved "https://registry.yarnpkg.com/@stoplight/http-spec/-/http-spec-7.0.2.tgz#010f10d1f721b0f6265ae3a851d0f41f62df0875" - integrity sha512-4DvT0w5goAhLxVbHfdzkMqGcTdi9bU4LmBrYNrZBOCFV4JPAHRERSBdI7F7n/MfgVvzxWb3Vftrh6pCgTd/+Jg== +"@stoplight/http-spec@^7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@stoplight/http-spec/-/http-spec-7.0.3.tgz#a27a3a72d429114e7994512f435312b5ee448c8b" + integrity sha512-r9Y8rT4RbqY7NWqSXjiqtBq0Nme2K5cArSX9gDPeuud8F4CwbizP7xkUwLdwDdHgoJkyIQ3vkFJpHzUVCQeOOA== dependencies: "@stoplight/json" "^3.18.1" "@stoplight/json-schema-generator" "1.0.2" @@ -1052,7 +1052,7 @@ fnv-plus "^1.3.1" lodash "^4.17.21" openapi3-ts "^2.0.2" - postman-collection "^4.1.2" + postman-collection "^4.1.3" tslib "^2.6.2" type-is "^1.6.18" @@ -6031,10 +6031,10 @@ postcss@^8.3.11: picocolors "^1.0.0" source-map-js "^1.0.2" -postman-collection@^4.1.2: - version "4.2.1" - resolved "https://registry.npmjs.org/postman-collection/-/postman-collection-4.2.1.tgz" - integrity sha512-DFLt3/yu8+ldtOTIzmBUctoupKJBOVK4NZO0t68K2lIir9smQg7OdQTBjOXYy+PDh7u0pSDvD66tm93eBHEPHA== +postman-collection@^4.1.3: + version "4.4.0" + resolved "https://registry.yarnpkg.com/postman-collection/-/postman-collection-4.4.0.tgz#6acb6e3796fcd9f6ac5a94e6894185e42387d7da" + integrity sha512-2BGDFcUwlK08CqZFUlIC8kwRJueVzPjZnnokWPtJCd9f2J06HBQpGL7t2P1Ud1NEsK9NHq9wdipUhWLOPj5s/Q== dependencies: "@faker-js/faker" "5.5.3" file-type "3.9.0"