Skip to content

Commit

Permalink
feat(http): added support to Deprecation header for deprecated operat…
Browse files Browse the repository at this point in the history
…ions #1563
  • Loading branch information
Łukasz Kużyński committed Sep 1, 2021
1 parent 2251b45 commit 1415319
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 26 deletions.
9 changes: 8 additions & 1 deletion packages/core/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ export function factory<Resource, Input, Output, Config extends IPrismConfig>(
})(components.logger.child({ name: 'NEGOTIATOR' }));

const forwardCall = (config: IPrismProxyConfig) =>
components.forward({ validations: config.errors ? validations : [], data }, config.upstream.href);
components.forward(
{
validations: config.errors ? validations : [],
data,
},
config.upstream.href,
resource
);

const produceOutput = isProxyConfig(config)
? pipe(
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type IPrismComponents<Resource, Input, Output, Config extends IPrismConfi
validateInput: ValidatorFn<Resource, Input>;
validateSecurity: ValidatorFn<Resource, Input>;
validateOutput: ValidatorFn<Resource, Output>;
forward: (input: IPrismInput<Input>, baseUrl: string) => ReaderTaskEither<Logger, Error, Output>;
forward: (input: IPrismInput<Input>, baseUrl: string, resource?: Resource) => ReaderTaskEither<Logger, Error, Output>;
mock: (opts: {
resource: Resource;
input: IPrismInput<Input>;
Expand Down
60 changes: 60 additions & 0 deletions packages/http/src/__tests__/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,54 @@ export const httpOperations: IHttpOperation[] = [
},
],
},
{
id: 'updateTodo',
deprecated: true,
method: 'patch',
path: '/todo/{todoId}',
request: {},
responses: [
{
code: '200',
headers: [],
contents: [
{
mediaType: 'application/json',
examples: [
{
key: 'application/json',
value: 'OK',
},
],
},
],
},
{
code: '400',
headers: [],
contents: [
{
mediaType: 'application/json',
examples: [
{
key: 'application/json',
value: {
message: 'error',
},
},
],
encodings: [],
},
],
},
],
},
];

export const httpOperationsByRef = {
deprecated: httpOperations[3],
};

export const httpInputs: IHttpRequest[] = [
{
method: 'get' as const,
Expand All @@ -284,8 +330,18 @@ export const httpInputs: IHttpRequest[] = [
'x-todos-publish': '2018-11-01T10:50:00.05Z',
},
},
{
method: 'patch',
url: {
path: '/todo/10',
},
},
];

export const httpInputsByRef = {
updateTodo: httpInputs[3],
};

export const httpRequests: Array<IPrismInput<IHttpRequest>> = [
{
validations: [],
Expand All @@ -302,6 +358,10 @@ export const httpRequests: Array<IPrismInput<IHttpRequest>> = [
],
data: httpInputs[1],
},
{
validations: [],
data: httpInputsByRef.updateTodo,
},
];

export const httpOutputs: IHttpResponse[] = [
Expand Down
92 changes: 71 additions & 21 deletions packages/http/src/forwarder/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@ import forward from '../index';
import { assertResolvesRight, assertResolvesLeft } from '@stoplight/prism-core/src/__tests__/utils';
import { keyBy, mapValues } from 'lodash';
import { hopByHopHeaders } from '../resources';
import { DiagnosticSeverity } from '@stoplight/types';
import { DiagnosticSeverity, Dictionary } from '@stoplight/types';

jest.mock('node-fetch');

function stubFetch({ json = {}, text = '', headers }: { headers: Dictionary<string>; text?: string; json?: unknown }) {
((fetch as unknown) as jest.Mock).mockResolvedValue({
headers: { get: (n: string) => headers[n], raw: () => mapValues(headers, (h: string) => h.split(' ')) },
json: jest.fn().mockResolvedValue(json),
text: jest.fn().mockResolvedValue(text),
});
}

describe('forward', () => {
const logger: any = {
error: jest.fn(),
Expand All @@ -17,12 +25,8 @@ describe('forward', () => {

describe('when POST method with json body', () => {
it('forwards request to upstream', () => {
const headers = { 'content-type': 'application/json' };

((fetch as unknown) as jest.Mock).mockResolvedValue({
headers: { get: (n: string) => headers[n], raw: () => mapValues(headers, (h: string) => h.split(' ')) },
json: jest.fn().mockResolvedValue({}),
text: jest.fn(),
stubFetch({
headers: { 'content-type': 'application/json' },
});

return assertResolvesRight(
Expand All @@ -49,12 +53,8 @@ describe('forward', () => {

describe('when POST method with circular json body', () => {
it('will fail and blame you', () => {
const headers = { 'content-type': 'application/json' };

((fetch as unknown) as jest.Mock).mockResolvedValue({
headers: { get: (n: string) => headers[n], raw: () => mapValues(headers, (h: string) => h.split(' ')) },
json: jest.fn().mockResolvedValue({}),
text: jest.fn(),
stubFetch({
headers: { 'content-type': 'application/json' },
});

const body = { x: {} };
Expand All @@ -79,11 +79,8 @@ describe('forward', () => {
describe('when POST method with string body', () => {
it('forwards request to upstream', () => {
const headers = { 'content-type': 'text/plain' };

((fetch as unknown) as jest.Mock).mockResolvedValue({
headers: { get: (n: string) => headers[n], raw: () => mapValues(headers, (h: string) => h.split(' ')) },
json: jest.fn(),
text: jest.fn().mockResolvedValue(''),
stubFetch({
headers,
});

return assertResolvesRight(
Expand Down Expand Up @@ -113,9 +110,8 @@ describe('forward', () => {
it('forwarder strips them all', () => {
const headers = mapValues(keyBy(hopByHopHeaders), () => 'n/a');

((fetch as unknown) as jest.Mock).mockReturnValue({
headers: { get: (n: string) => headers[n], raw: () => mapValues(headers, (h: string) => h.split(' ')) },
text: jest.fn().mockResolvedValue(''),
stubFetch({
headers,
});

return assertResolvesRight(
Expand Down Expand Up @@ -144,4 +140,58 @@ describe('forward', () => {
e => expect(e).toHaveProperty('status', 422)
));
});

describe('and operation is marked as deprected', () => {
it('will add "Deprecation" header if not present in response', () => {
stubFetch({
headers: { 'content-type': 'text/plain' },
});

assertResolvesRight(
forward(
{
validations: [],
data: {
method: 'post',
url: { path: '/test' },
},
},
'http://example.com',
{
deprecated: true,
method: 'post',
path: '/test',
responses: [],
id: 'test',
}
)(logger),
e => expect(e.headers).toHaveProperty('deprecation', 'true')
);
});

it('will omit "Deprecation" header if already defined in response', () => {
stubFetch({ headers: { 'content-type': 'text/plain', deprecation: 'foo' } });

assertResolvesRight(
forward(
{
validations: [],
data: {
method: 'post',
url: { path: '/test' },
},
},
'http://example.com',
{
deprecated: true,
method: 'post',
path: '/test',
responses: [],
id: 'test',
}
)(logger),
e => expect(e.headers).toHaveProperty('deprecation', 'foo')
);
});
});
});
9 changes: 8 additions & 1 deletion packages/http/src/forwarder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const { version: prismVersion } = require('../../package.json'); // eslint-disab

const forward: IPrismComponents<IHttpOperation, IHttpRequest, IHttpResponse, IHttpConfig>['forward'] = (
{ data: input, validations }: IPrismInput<IHttpRequest>,
baseUrl: string
baseUrl: string,
resource
): RTE.ReaderTaskEither<Logger, Error, IHttpResponse> => logger =>
pipe(
NEA.fromArray(validations),
Expand Down Expand Up @@ -68,6 +69,12 @@ const forward: IPrismComponents<IHttpOperation, IHttpRequest, IHttpResponse, IHt
return TE.right(undefined);
}),
TE.chain(parseResponse),
TE.map(response => {
if (resource && resource.deprecated && response.headers && !response.headers.deprecation) {
response.headers.deprecation = 'true';
}
return response;
}),
TE.map(stripHopByHopHeaders)
);

Expand Down
17 changes: 16 additions & 1 deletion packages/http/src/mocker/__tests__/functional.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Ajv from 'ajv';

import { createLogger } from '@stoplight/prism-core';
import { httpOperations, httpRequests } from '../../__tests__/fixtures';
import { httpOperations, httpRequests, httpOperationsByRef } from '../../__tests__/fixtures';
import { assertLeft, assertRight } from '@stoplight/prism-core/src/__tests__/utils';
import mock from '../index';

Expand Down Expand Up @@ -254,4 +254,19 @@ describe('http mocker', () => {
});
});
});

describe('for operation that is deprecated', () => {
it('should set "Deprecation" header', () => {
const response = mock({
config: { dynamic: false },
resource: httpOperationsByRef.deprecated,
input: httpRequests[2],
})(logger);

assertRight(response, result => {
expect(result.headers).toHaveProperty('deprecation', 'true');
expect(result.statusCode).toEqual(200);
});
});
});
});
25 changes: 24 additions & 1 deletion packages/http/src/mocker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const mock: IPrismComponents<IHttpOperation, IHttpRequest, IHttpResponse, IHttpM
return config;
}),
R.chain(mockConfig => negotiateResponse(mockConfig, input, resource)),
R.chain(result => negotiateDeprecation(result, resource)),
R.chain(result => assembleResponse(result, payloadGenerator)),
R.chain(response =>
/* Note: This is now just logging the errors without propagating them back. This might be moved as a first
Expand Down Expand Up @@ -195,7 +196,7 @@ function negotiateResponse(
mockConfig: IHttpOperationConfig,
input: IPrismInput<IHttpRequest>,
resource: IHttpOperation
) {
): RE.ReaderEither<Logger, Error, IHttpNegotiationResult> {
const { [DiagnosticSeverity.Error]: errors, [DiagnosticSeverity.Warning]: warnings } = groupBy(
input.validations,
validation => validation.severity
Expand All @@ -217,6 +218,25 @@ function negotiateResponse(
}
}

function negotiateDeprecation(
result: E.Either<Error, IHttpNegotiationResult>,
httpOperation: IHttpOperation
): RE.ReaderEither<Logger, Error, IHttpNegotiationResult> {
if (httpOperation.deprecated) {
return pipe(
withLogger(logger => {
logger.info('Adding "Deprecation" header since operation is deprecated');
return result;
}),
RE.map(result => ({
...result,
deprecated: true,
}))
);
}
return RE.fromEither(result);
}

const assembleResponse = (
result: E.Either<Error, IHttpNegotiationResult>,
payloadGenerator: PayloadGenerator
Expand All @@ -238,6 +258,9 @@ const assembleResponse = (
...(negotiationResult.mediaType && {
'Content-type': negotiationResult.mediaType,
}),
...(negotiationResult.deprecated && {
deprecation: 'true',
}),
},
body: mockedBody,
};
Expand Down
1 change: 1 addition & 0 deletions packages/http/src/mocker/negotiator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface IHttpNegotiationResult {
bodyExample?: ContentExample;
headers: IHttpHeaderParam[];
schema?: JSONSchema;
deprecated?: boolean;
}

export type NegotiationOptions = IHttpOperationConfig;
Expand Down

0 comments on commit 1415319

Please sign in to comment.