Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expand fs-ts in Prism's router #402

Merged
merged 14 commits into from Jul 9, 2019
175 changes: 99 additions & 76 deletions packages/core/src/factory.ts
@@ -1,4 +1,6 @@
import { DiagnosticSeverity } from '@stoplight/types';
import { toError } from 'fp-ts/lib/Either';
import { fromEither, left2v, right2v, tryCatch } from 'fp-ts/lib/TaskEither';
import { configMergerFactory, PartialPrismConfig, PrismConfig } from '.';
import { IPrism, IPrismComponents, IPrismConfig, IPrismDiagnostic, PickRequired, ProblemJsonError } from './types';

Expand Down Expand Up @@ -39,93 +41,114 @@ export function factory<Resource, Input, Output, Config, LoadOpts>(
const configObj: Config | undefined = configMerger(input);
const inputValidations: IPrismDiagnostic[] = [];

// find the correct resource
let resource: Resource | undefined;
if (components.router) {
try {
resource = components.router.route({ resources, input, config: configObj }, defaultComponents.router);
} catch (error) {
// rethrow error we if we're attempting to mock
if ((configObj as IPrismConfig).mock) {
throw error;
}
const { message, name, status } = error as ProblemJsonError;
// otherwise let's just stack it on the inputValidations
// when someone simply wants to hit an URL, don't block them
inputValidations.push({
message,
source: name,
code: status,
severity: DiagnosticSeverity.Warning,
});
}
}
return components.router
.route({ resources, input, config: configObj }, defaultComponents.router)
.fold(
error => {
// rethrow error we if we're attempting to mock
if ((configObj as IPrismConfig).mock) {
return left2v(error);
}

// validate input
if (resource && components.validator && components.validator.validateInput) {
inputValidations.push(
...(await components.validator.validateInput(
{
resource,
input,
config: configObj,
},
defaultComponents.validator,
)),
);
}
const { message, name, status } = error as ProblemJsonError;
// otherwise let's just stack it on the inputValidations
// when someone simply wants to hit an URL, don't block them
inputValidations.push({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing things to array which is defined outside function chains feels not right. How about using validations? (https://dev.to/gcanti/getting-started-with-fp-ts-either-vs-validation-5eja - Validation section in the middle)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! That's exactly what I wanted to do. However I'm proceeding with baby steps here as — you've probably noticed — it's hard to digest all in one place; moreover we're close to the release and I didn't want to change that many things at the time.

…but yes it's exactly the direction I want to take!

message,
source: name,
code: status,
severity: DiagnosticSeverity.Warning,
});

// build output
let output: Output | undefined;
if (resource && components.mocker && (configObj as IPrismConfig).mock) {
// generate the response
output = components.mocker
.mock(
{
resource,
input: { validations: { input: inputValidations }, data: input },
config: configObj,
return right2v<Error, Resource | undefined>(undefined);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right2v, whoa what a lovely interface :P I have a suggestion
right2lolz because why not? Ok enough sarcazm for today

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually a good point — it has been fixed in 2.x but I didn't want to deal with breaking changes right now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll get less PR from random people. Entry threshold is set high ;)

},
defaultComponents.mocker,
value => right2v(value),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a problem finding documentation for right2v. Isn't it deprecated?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is deprecated in 2.x — Prism is currently on 1.9. I'll migrate it soon though.

)
.run(components.logger.child({ name: 'NEGOTIATOR' }))
.fold(
e => {
throw e;
},
r => r,
);
} else if (components.forwarder) {
// forward request and set output from response
output = await components.forwarder.forward(
{
resource,
input: { validations: { input: inputValidations }, data: input },
config: configObj,
},
defaultComponents.forwarder,
);
}
.chain(resource => {
// validate input
if (resource && components.validator && components.validator.validateInput) {
inputValidations.push(
...components.validator.validateInput(
{
resource,
input,
config: configObj,
},
defaultComponents.validator,
),
);
}

if (resource && components.mocker && (configObj as IPrismConfig).mock) {
// generate the response
return fromEither(
components.mocker
.mock(
{
resource,
input: { validations: { input: inputValidations }, data: input },
config: configObj,
},
defaultComponents.mocker,
)
.run(components.logger.child({ name: 'NEGOTIATOR' })),
).map(output => ({ output, resource }));
} else if (components.forwarder) {
// forward request and set output from response
return components.forwarder
.fforward(
{
resource,
input: { validations: { input: inputValidations }, data: input },
config: configObj,
},
defaultComponents.forwarder,
)
.map(output => ({ output, resource }));
}

// validate output
let outputValidations: IPrismDiagnostic[] = [];
if (resource && components.validator && components.validator.validateOutput) {
outputValidations = await components.validator.validateOutput(
{
resource,
output,
config: configObj,
},
defaultComponents.validator,
);
return left2v(new Error('Nor forwarder nor mocker has been selected. Something is wrong'));
})
.map(({ output, resource }) => {
let outputValidations: IPrismDiagnostic[] = [];
if (resource && components.validator && components.validator.validateOutput) {
outputValidations = components.validator.validateOutput(
{
resource,
output,
config: configObj,
},
defaultComponents.validator,
);
}

return {
input,
output,
validations: {
input: inputValidations,
output: outputValidations,
},
};
})
.run()
.then(v =>
v.fold(
e => {
throw e;
},
o => o,
),
);
}

return {
input,
output,
output: undefined,
validations: {
input: inputValidations,
output: outputValidations,
input: [],
output: [],
},
};
},
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/types.ts
@@ -1,6 +1,7 @@
import { IDiagnostic } from '@stoplight/types';
import { Either } from 'fp-ts/lib/Either';
import { Reader } from 'fp-ts/lib/Reader';
import { TaskEither } from 'fp-ts/lib/TaskEither';
import { Logger } from 'pino';
export type IPrismDiagnostic = Omit<IDiagnostic, 'range'>;

Expand Down Expand Up @@ -43,14 +44,18 @@ export interface IRouter<Resource, Input, Config> {
route: (
opts: { resources: Resource[]; input: Input; config?: Config },
defaultRouter?: IRouter<Resource, Input, Config>,
) => Resource;
) => Either<Error, Resource>;
}

export interface IForwarder<Resource, Input, Config, Output> {
forward: (
opts: { resource?: Resource; input: IPrismInput<Input>; config?: Config },
defaultForwarder?: IForwarder<Resource, Input, Config, Output>,
) => Promise<Output>;
fforward: (
opts: { resource?: Resource; input: IPrismInput<Input>; config?: Config },
defaultForwarder?: IForwarder<Resource, Input, Config, Output>,
) => TaskEither<Error, Output>;
}

export interface IMocker<Resource, Input, Config, Output> {
Expand Down
11 changes: 3 additions & 8 deletions packages/http-server/src/__tests__/server.oas.spec.ts
Expand Up @@ -188,14 +188,9 @@ describe.each([['petstore.oas2.json'], ['petstore.oas3.json']])('server %s', fil
checkErrorPayloadShape(response.payload);
});

it.skip('should automagically provide the parameters when not provided in the query string and a default is defined', async () => {
const response = await server.fastify.inject({
method: 'GET',
url: '/pets/findByStatus',
});

expect(response.statusCode).toBe(200);
});
test.todo(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Such a decent test :( why remove it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was skipped — I'm fine putting it back but it's not getting executed anyway. I'd rather see a todo that says "Hey write this" instead of a skip that you do not easily see

'should automagically provide the parameters when not provided in the query string and a default is defined',
);

it('should support multiple param values', async () => {
const response = await server.fastify.inject({
Expand Down
33 changes: 3 additions & 30 deletions packages/http/src/__tests__/http-prism-instance.spec.ts
Expand Up @@ -201,18 +201,8 @@ describe('Http Client .process', () => {
});
});

describe('GET /pet without an optional body parameter', () => {
// TODO will be fixed by https://stoplightio.atlassian.net/browse/SO-260
xit('returns 200 response', async () => {
const response = await prism.process({
method: 'get',
url: { path: '/pet' },
});

expect(response.output).toBeDefined();
expect(response.output!.statusCode).toEqual(200);
});
});
// TODO will be fixed by https://stoplightio.atlassian.net/browse/SO-260
test.todo('GET /pet without an optional body parameter');

describe('when processing GET /pet/findByStatus', () => {
it('with valid query params returns generated body', async () => {
Expand Down Expand Up @@ -269,24 +259,7 @@ describe('Http Client .process', () => {
});

// TODO: will be fixed by https://stoplightio.atlassian.net/browse/SO-259
xit('with invalid body returns validation errors', () => {
return expect(
prism.process({
method: 'get',
url: {
path: '/pet/findByStatus',
query: {
status: ['available'],
},
},
body: {
id: 'should not be a string',
status: 'should be one of "placed", "approved", "delivered"',
complete: 'should be a boolean',
},
}),
).rejects.toThrowError(ProblemJsonError.fromTemplate(UNPROCESSABLE_ENTITY));
});
test.todo('with invalid body returns validation errors');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 🔥 🔥 You're removing my lovely tests :( 🔥

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was skipped — I'm fine putting it back but it's not getting executed anyway. I'd rather see a todo that says "Hey write this" instead of a skip that you do not easily see

});
});

Expand Down
72 changes: 46 additions & 26 deletions packages/http/src/forwarder/HttpForwarder.ts
@@ -1,6 +1,8 @@
import { IForwarder, IPrismInput } from '@stoplight/prism-core';
import { IHttpOperation, IServer } from '@stoplight/types';
import axios, { CancelToken } from 'axios';
import { toError } from 'fp-ts/lib/Either';
import { TaskEither, tryCatch } from 'fp-ts/lib/TaskEither';
import { URL } from 'url';
import { NO_BASE_URL_ERROR } from '../router/errors';
import { IHttpConfig, IHttpNameValue, IHttpRequest, IHttpResponse, ProblemJsonError } from '../types';
Expand All @@ -12,35 +14,53 @@ export class HttpForwarder implements IForwarder<IHttpOperation, IHttpRequest, I
timeout?: number;
cancelToken?: CancelToken;
}): Promise<IHttpResponse> {
const inputData = opts.input.data;
const baseUrl =
opts.resource && opts.resource.servers && opts.resource.servers.length > 0
? this.resolveServerUrl(opts.resource.servers[0])
: inputData.url.baseUrl;
return this.fforward(opts)
.fold(
e => {
throw e;
},
o => o,
)
.run();
}

if (!baseUrl) {
throw ProblemJsonError.fromTemplate(NO_BASE_URL_ERROR);
}
public fforward(opts: {
resource?: IHttpOperation;
input: IPrismInput<IHttpRequest>;
timeout?: number;
cancelToken?: CancelToken;
}): TaskEither<Error, IHttpResponse> {
return tryCatch<Error, IHttpResponse>(async () => {
const inputData = opts.input.data;
const baseUrl =
opts.resource && opts.resource.servers && opts.resource.servers.length > 0
? this.resolveServerUrl(opts.resource.servers[0])
: inputData.url.baseUrl;

const response = await axios({
method: inputData.method,
baseURL: baseUrl,
url: inputData.url.path,
params: inputData.url.query,
responseType: 'text',
data: inputData.body,
headers: this.updateHostHeaders(baseUrl, inputData.headers),
validateStatus: () => true,
timeout: Math.max(opts.timeout || 0, 0),
...(opts.cancelToken !== undefined && { cancelToken: opts.cancelToken }),
});
if (!baseUrl) {
throw ProblemJsonError.fromTemplate(NO_BASE_URL_ERROR);
}

const response = await axios({
method: inputData.method,
baseURL: baseUrl,
url: inputData.url.path,
params: inputData.url.query,
responseType: 'text',
data: inputData.body,
headers: this.updateHostHeaders(baseUrl, inputData.headers),
validateStatus: () => true,
timeout: Math.max(opts.timeout || 0, 0),
...(opts.cancelToken !== undefined && { cancelToken: opts.cancelToken }),
});

return {
statusCode: response.status,
headers: response.headers,
body: response.data,
responseType: (response.request && response.request.responseType) || '',
};
return {
statusCode: response.status,
headers: response.headers,
body: response.data,
responseType: (response.request && response.request.responseType) || '',
};
}, toError);
}

private updateHostHeaders(baseUrl: string, headers?: IHttpNameValue) {
Expand Down