Skip to content

Commit

Permalink
feat: add --seed CLI flag for deterministic generation of dynamic exa…
Browse files Browse the repository at this point in the history
…mples (#2594)
  • Loading branch information
ilanashapiro authored Oct 22, 2024
1 parent 658b16b commit 8edc1cc
Show file tree
Hide file tree
Showing 12 changed files with 1,497 additions and 1,507 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@types/pino": "6.3.12",
"@types/postman-collection": "^3.5.7",
"@types/raw-body": "^2.3.0",
"@types/seedrandom": "^3.0.8",
"@types/signale": "^1.4.2",
"@types/split2": "^2.1.6",
"@types/type-is": "^1.6.3",
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,16 @@ const mockCommand: CommandModule = {
boolean: true,
default: false,
},
seed: {
description: `Provide a seed so that Prism generates dynamic examples deterministically`,
string: true,
demandOption: true,
default: null
},
}),
handler: async parsedArgs => {
parsedArgs.jsonSchemaFakerFillProperties = parsedArgs['json-schema-faker-fillProperties'];
const { multiprocess, dynamic, port, host, cors, document, errors, verboseLevel, ignoreExamples, jsonSchemaFakerFillProperties } =
const { multiprocess, dynamic, port, host, cors, document, errors, verboseLevel, ignoreExamples, seed, jsonSchemaFakerFillProperties } =
parsedArgs as unknown as CreateMockServerOptions;

const createPrism = multiprocess ? createMultiProcessPrism : createSingleProcessPrism;
Expand All @@ -51,6 +57,7 @@ const mockCommand: CommandModule = {
errors,
verboseLevel,
ignoreExamples,
seed,
jsonSchemaFakerFillProperties,
};

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const proxyCommand: CommandModule = {
'validateRequest',
'verboseLevel',
'ignoreExamples',
'seed',
'upstreamProxy',
'jsonSchemaFakerFillProperties'
);
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/util/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ async function createPrismServerWithLogger(options: CreateBaseServerOptions, log
checkSecurity: true,
errors: options.errors,
upstreamProxy: undefined,
mock: { dynamic: options.dynamic, ignoreExamples: options.ignoreExamples },
mock: { dynamic: options.dynamic, ignoreExamples: options.ignoreExamples, seed: options.seed },
};

const config: IHttpConfig = isProxyServerOptions(options)
Expand Down Expand Up @@ -155,6 +155,7 @@ type CreateBaseServerOptions = {
errors: boolean;
verboseLevel: string;
ignoreExamples: boolean;
seed: string;
jsonSchemaFakerFillProperties: boolean;
};

Expand Down
52 changes: 51 additions & 1 deletion packages/http-server/src/__tests__/server.oas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,56 @@ describe('Prefer header overrides', () => {
it('second object should be a dynamic object', () => expect(secondPayload).toBeInstanceOf(Array));
});
});

describe('and I send requests with a dynamic seed set in the Prefer header', () => {
describe('and I hit the same endpoint twice with the same seed', () => {
let payload: unknown;
let secondPayload: unknown;

beforeAll(async () => {
payload = await fetch(new URL('/no_auth/pets?name=joe', server.address), {
method: 'GET',
headers: { prefer: 'seed=test_seed' },
}).then(r => r.json());
secondPayload = await fetch(new URL('/no_auth/pets?name=joe', server.address), {
method: 'GET',
headers: { prefer: 'seed=test_seed' },
}).then(r => r.json());
});

it('should return the same object', () => expect(payload).toStrictEqual(secondPayload));
});
});
});
});

describe('Seeded dynamic examples', () => {
let server: ThenArg<ReturnType<typeof instantiatePrism>>;

beforeAll(async () => {
server = await instantiatePrism(resolve(__dirname, 'fixtures', 'petstore.no-auth.oas3.yaml'), {
mock: { dynamic: true, seed: "test_seed" },
});
});

afterAll(() => server.close());

describe('when running the server with dynamic set to true and a seed set', () => {
describe('and I hit the same endpoint twice', () => {
let payload: unknown;
let secondPayload: unknown;

beforeAll(async () => {
payload = await fetch(new URL('/no_auth/pets?name=joe', server.address), { method: 'GET' }).then(r =>
r.json()
);
secondPayload = await fetch(new URL('/no_auth/pets?name=joe', server.address), { method: 'GET' }).then(r =>
r.json()
);
});

it('should return the same object', () => expect(payload).toStrictEqual(secondPayload));
});
});
});

Expand Down Expand Up @@ -182,7 +232,7 @@ describe('Ignore examples', () => {
});
});

describe('and I send a request with dyanamic set to True', () => {
describe('and I send a request with dynamic 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',
Expand Down
10 changes: 8 additions & 2 deletions packages/http-server/src/getHttpConfigFromRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const IntegerFromString = D.parse<string, number>(s => {
const PreferencesDecoder = D.partial({
code: pipe(D.string, IntegerFromString),
dynamic: pipe(D.string, BooleanFromString),
seed: D.string,
example: D.string,
});

Expand All @@ -27,13 +28,18 @@ export const getHttpConfigFromRequest = (
const preferences: unknown =
req.headers && req.headers['prefer']
? parsePreferHeader(req.headers['prefer'])
: { code: req.url.query?.__code, dynamic: req.url.query?.__dynamic, example: req.url.query?.__example };
: {
code: req.url.query?.__code,
dynamic: req.url.query?.__dynamic,
seed: req.url.query?.__seed,
example: req.url.query?.__example,
};

return pipe(
PreferencesDecoder.decode(preferences),
E.bimap(
err => ProblemJsonError.fromTemplate(UNPROCESSABLE_ENTITY, D.draw(err)),
parsed => ({ code: parsed?.code, exampleKey: parsed?.example, dynamic: parsed?.dynamic })
parsed => ({ code: parsed?.code, exampleKey: parsed?.example, dynamic: parsed?.dynamic, seed: parsed?.seed })
)
);
};
1 change: 1 addition & 0 deletions packages/http/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"node-fetch": "^2.6.5",
"parse-multipart-data": "^1.5.0",
"pino": "^6.13.3",
"seedrandom": "^3.0.5",
"tslib": "^2.3.1",
"type-is": "^1.6.18",
"uri-template-lite": "^22.9.0",
Expand Down
11 changes: 9 additions & 2 deletions packages/http/src/mocker/generator/JSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { IHttpContent, IHttpOperation, IHttpParam } from '@stoplight/types';
import { pipe } from 'fp-ts/function';
import * as E from 'fp-ts/lib/Either';
import { stripWriteOnlyProperties } from '../../utils/filterRequiredProperties';
import * as seedrandom from 'seedrandom';

// necessary as workaround broken types in json-schema-faker
// @ts-ignore
Expand Down Expand Up @@ -64,7 +65,8 @@ resetGenerator();
export function generate(
resource: IHttpOperation | IHttpParam | IHttpContent,
bundle: unknown,
source: JSONSchema
source: JSONSchema,
seed?: string
): Either<Error, unknown> {
return pipe(
stripWriteOnlyProperties(source),
Expand All @@ -73,7 +75,12 @@ export function generate(
tryCatch(
// necessary as workaround broken types in json-schema-faker
// @ts-ignore
() => sortSchemaAlphabetically(JSONSchemaFaker.generate({ ...cloneDeep(updatedSource), __bundled__: bundle })),
() => {
if (seed) {
JSONSchemaFaker.option('random', seedrandom(seed))
}
return sortSchemaAlphabetically(JSONSchemaFaker.generate({ ...cloneDeep(updatedSource), __bundled__: bundle }))
},
toError
)
)
Expand Down
22 changes: 22 additions & 0 deletions packages/http/src/mocker/generator/__tests__/JSONSchema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ describe('JSONSchema generator', () => {
expect(emailRegExp.test(name)).toBeFalsy();
});
});

it('will have a deterministic dynamic response if the seed is set', () => {
const result1 = generate(operation, {}, schema, "test_seed");
const result2 = generate(operation, {}, schema, "test_seed");

assertRight(result1, instance1 => {
assertRight(result2, instance2 => {
expect(instance1).toEqual(instance2);
});
});
});

it('will have a nondeterministic dynamic response if the seed is not set', () => {
const result1 = generate(operation, {}, schema);
const result2 = generate(operation, {}, schema);

assertRight(result1, instance1 => {
assertRight(result2, instance2 => {
expect(instance1).not.toEqual(instance2);
});
});
});
});

describe('when used with a schema with a string and email as format', () => {
Expand Down
12 changes: 9 additions & 3 deletions packages/http/src/mocker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import helpers from './negotiator/NegotiatorHelpers';
import { IHttpNegotiationResult } from './negotiator/types';
import { runCallback } from './callback/callbacks';
import { logRequest, logResponse } from '../utils/logger';
import { JSONSchema } from '../types';
import {
decodeUriEntities,
deserializeFormBody,
Expand All @@ -57,9 +58,14 @@ const mock: IPrismComponents<IHttpOperation, IHttpRequest, IHttpResponse, IHttpM
input,
config,
}) => {
const payloadGenerator: PayloadGenerator = config.dynamic
? partial(generate, resource, resource['__bundle__'])
: partial(generateStatic, resource);
function createPayloadGenerator(config: IHttpOperationConfig, resource: IHttpOperation): PayloadGenerator {
return (source: JSONSchema) => {
return config.dynamic
? generate(resource, resource['__bundled__'], source, config.seed)
: generateStatic(resource, source);
};
}
const payloadGenerator = createPayloadGenerator(config, resource);

return pipe(
withLogger(logger => {
Expand Down
1 change: 1 addition & 0 deletions packages/http/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface IHttpOperationConfig {
exampleKey?: string;
dynamic: boolean;
ignoreExamples?: boolean;
seed?: string;
}

export type IHttpMockConfig = Overwrite<IPrismMockConfig, { mock: IHttpOperationConfig }>;
Expand Down
Loading

0 comments on commit 8edc1cc

Please sign in to comment.