From ed41dca89e5ad673f1a0d813b403a44de7e367b2 Mon Sep 17 00:00:00 2001 From: Brenda Rearden Date: Fri, 22 Mar 2024 08:06:55 -0700 Subject: [PATCH] feat: STOP-243 - create prism instance with full spec (#2501) Co-authored-by: Ed Vinyard --- packages/cli/package.json | 1 - .../src/commands/__tests__/commands.spec.ts | 2 +- packages/cli/src/operations.ts | 57 +---- 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 ++--- packages/http/package.json | 2 + .../src/__tests__/http-prism-instance.spec.ts | 2 +- .../src/__tests__/instance-with-spec.spec.ts | 232 ++++++++++++++++++ packages/http/src/index.ts | 2 + packages/http/src/instanceWithSpec.ts | 35 +++ .../src/mocker/__tests__/HttpMocker.spec.ts | 2 +- packages/http/src/mocker/index.ts | 14 +- .../src/utils}/__tests__/operations.spec.ts | 2 +- packages/http/src/utils/operations.ts | 55 +++++ yarn.lock | 18 +- 17 files changed, 434 insertions(+), 158 deletions(-) create mode 100644 packages/http/src/__tests__/instance-with-spec.spec.ts create mode 100644 packages/http/src/instanceWithSpec.ts rename packages/{cli/src/util => http/src/utils}/__tests__/operations.spec.ts (97%) create mode 100644 packages/http/src/utils/operations.ts 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/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/operations.ts b/packages/cli/src/operations.ts index 518b68266..3bf437683 100644 --- a/packages/cli/src/operations.ts +++ b/packages/cli/src/operations.ts @@ -1,55 +1,2 @@ -import { transformOas3Operations } from '@stoplight/http-spec/oas3/operation'; -import { transformOas2Operations } from '@stoplight/http-spec/oas2/operation'; -import { transformPostmanCollectionOperations } from '@stoplight/http-spec/postman/operation'; -import * as $RefParser from '@stoplight/json-schema-ref-parser'; -import { HTTPResolverOptions } from '@stoplight/json-schema-ref-parser'; -import { bundleTarget, decycle } from '@stoplight/json'; -import { IHttpOperation } from '@stoplight/types'; -import { get } from 'lodash'; -import * as os from 'os'; -import type { Spec } from 'swagger-schema-official'; -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 httpResolverOpts: HTTPResolverOptions = { - headers: { - 'User-Agent': `PrismMockServer/${prismVersion} (${os.type()} ${os.arch()} ${os.release()})`, - }, - }; - const result = decycle( - await new $RefParser().dereference(specFilePathOrObject, { resolve: { http: httpResolverOpts } }) - ); - - let operations: IHttpOperation[] = []; - if (isOpenAPI2(result)) operations = transformOas2Operations(result); - else if (isOpenAPI3(result)) operations = transformOas3Operations(result); - else if (isPostmanCollection(result)) operations = transformPostmanCollectionOperations(result); - else throw new Error('Unsupported document format'); - - operations.forEach((op, i, ops) => { - ops[i] = bundleTarget({ - document: { - ...result, - __target__: op, - }, - path: '#/__target__', - cloneDocument: false, - }); - }); - - return operations; -} - -function isOpenAPI2(document: unknown): document is Spec { - return get(document, 'swagger') !== undefined; -} - -function isOpenAPI3(document: unknown): document is OpenAPIObject { - return get(document, 'openapi') !== undefined; -} - -function isPostmanCollection(document: unknown): document is CollectionDefinition { - return Array.isArray(get(document, 'item')) && get(document, 'info.name') !== undefined; -} +// add to keep this from being a breaking change. +export { getHttpOperationsFromSpec } from '@stoplight/prism-http'; 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/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/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..2c1ca76ce --- /dev/null +++ b/packages/http/src/__tests__/instance-with-spec.spec.ts @@ -0,0 +1,232 @@ +import { createLogger } from '@stoplight/prism-core'; +import { basename, resolve } from 'path'; +import { IHttpRequest, ProblemJsonError } from '../'; +import { UNPROCESSABLE_ENTITY } from '../mocker/errors'; +import { NO_PATH_MATCHED_ERROR, NO_SERVER_MATCHED_ERROR } from '../router/errors'; +import { createAndCallPrismInstanceWithSpec, PrismErrorResult, PrismOkResult } from '../instanceWithSpec'; +import { IHttpConfig } from '../types'; + +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); + expect(result.result).toBe('ok'); + const output = (result as PrismOkResult).response.output; + expect(output).toBeDefined(); + expect(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); + expect(result.result).toBe('ok'); + const output = (result as PrismOkResult).response.output; + expect(output).toBeDefined(); + expect(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); + expect(result.result).toBe('error'); + expect((result as PrismErrorResult).error).toMatchObject( + ProblemJsonError.fromTemplate(NO_SERVER_MATCHED_ERROR) + ); + }); + }); + + 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); + expect(result.result).toBe('error'); + expect((result as PrismErrorResult).error).toMatchObject( + ProblemJsonError.fromTemplate(NO_SERVER_MATCHED_ERROR) + ); + }); + }); + }); + + 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); + expect(result.result).toBe('error'); + expect((result as PrismErrorResult).error).toMatchObject(ProblemJsonError.fromTemplate(NO_PATH_MATCHED_ERROR)); + }); + }); + + 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); + 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 () => { + const request: IHttpRequest = { + method: 'get', + url: { + path: '/pet/findByStatus', + }, + }; + const result = await createAndCallPrismInstanceWithSpec(specPath, config, request, logger); + 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 () => { + 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); + expect(result.result).toBe('ok'); + expect((result as PrismOkResult).response.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); + expect(result).toBeDefined(); + 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 () => { + const request: IHttpRequest = { + method: 'get', + url: { + path: '/pet/login', + }, + }; + const result = await createAndCallPrismInstanceWithSpec(noRefsPetstoreMinimalOas2Path, config, request, logger); + expect(result.result).toBe('error'); + expect((result as PrismErrorResult).error).toMatchObject(ProblemJsonError.fromTemplate(UNPROCESSABLE_ENTITY)); + }); + }); + + 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); + 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/index.ts b/packages/http/src/index.ts index fc1676559..f151c559a 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -12,6 +12,8 @@ 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 { createAndCallPrismInstanceWithSpec, PrismErrorResult, PrismOkResult } from './instanceWithSpec'; 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..4ddec5e56 --- /dev/null +++ b/packages/http/src/instanceWithSpec.ts @@ -0,0 +1,35 @@ +import { createInstance } from './index'; +import { getHttpOperationsFromSpec } from './utils/operations'; +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 { IPrismOutput } from '@stoplight/prism-core'; + +export type PrismOkResult = { + result: 'ok'; + response: IPrismOutput; +}; + +export type PrismErrorResult = { + result: 'error'; + error: Error; +}; + +export async function createAndCallPrismInstanceWithSpec( + spec: string | object, + options: IHttpConfig, + 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: 'ok', response: result.right }; + } + if (isLeft(result)) { + return { result: 'error', error: result.left }; + } + throw new Error('Unexpected Result'); +} diff --git a/packages/http/src/mocker/__tests__/HttpMocker.spec.ts b/packages/http/src/mocker/__tests__/HttpMocker.spec.ts index 96c85e25c..9ad103788 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', - } + }, }), }), }) diff --git a/packages/http/src/mocker/index.ts b/packages/http/src/mocker/index.ts index 905e719e5..ff24e2a3f 100644 --- a/packages/http/src/mocker/index.ts +++ b/packages/http/src/mocker/index.ts @@ -43,7 +43,7 @@ import { deserializeFormBody, findContentByMediaTypeOrFirst, splitUriParams, - parseMultipartFormDataParams + parseMultipartFormDataParams, } from '../validator/validators/body'; import { parseMIMEHeader } from '../validator/validators/headers'; import { NonEmptyArray } from 'fp-ts/NonEmptyArray'; @@ -151,10 +151,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 +386,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) { 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/operations.ts b/packages/http/src/utils/operations.ts new file mode 100644 index 000000000..255cf1483 --- /dev/null +++ b/packages/http/src/utils/operations.ts @@ -0,0 +1,55 @@ +import { transformOas3Operations } from '@stoplight/http-spec/oas3/operation'; +import { transformOas2Operations } from '@stoplight/http-spec/oas2/operation'; +import { transformPostmanCollectionOperations } from '@stoplight/http-spec/postman/operation'; +import * as $RefParser from '@stoplight/json-schema-ref-parser'; +import { HTTPResolverOptions } from '@stoplight/json-schema-ref-parser'; +import { bundleTarget, decycle } from '@stoplight/json'; +import { IHttpOperation } from '@stoplight/types'; +import { get } from 'lodash'; +import * as os from 'os'; +import type { Spec } from 'swagger-schema-official'; +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 httpResolverOpts: HTTPResolverOptions = { + headers: { + 'User-Agent': `PrismMockServer/${prismVersion} (${os.type()} ${os.arch()} ${os.release()})`, + }, + }; + const result = decycle( + await new $RefParser().dereference(specFilePathOrObject, { resolve: { http: httpResolverOpts } }) + ); + + let operations: IHttpOperation[] = []; + if (isOpenAPI2(result)) operations = transformOas2Operations(result); + else if (isOpenAPI3(result)) operations = transformOas3Operations(result); + else if (isPostmanCollection(result)) operations = transformPostmanCollectionOperations(result); + else throw new Error('Unsupported document format'); + + operations.forEach((op, i, ops) => { + ops[i] = bundleTarget({ + document: { + ...result, + __target__: op, + }, + path: '#/__target__', + cloneDocument: false, + }); + }); + + return operations; +} + +function isOpenAPI2(document: unknown): document is Spec { + return get(document, 'swagger') !== undefined; +} + +function isOpenAPI3(document: unknown): document is OpenAPIObject { + return get(document, 'openapi') !== undefined; +} + +function isPostmanCollection(document: unknown): document is CollectionDefinition { + return Array.isArray(get(document, 'item')) && get(document, 'info.name') !== undefined; +} 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"