Skip to content

Commit

Permalink
feat: support using generative ai
Browse files Browse the repository at this point in the history
  • Loading branch information
zoubingwu committed Jul 4, 2024
1 parent 76b927c commit f99d8a3
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 100 deletions.
2 changes: 1 addition & 1 deletion bin/cli.js
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
#! /usr/bin/env node
require('../dist/cli')
require('../dist/cli');
10 changes: 1 addition & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,12 @@ const cli = cac();

cli
.command('<spec>', 'Generating msw mock definitions with random fake data.')
.option('-o, --output <file>', `Output file path such as \`./mock.js\`, without it'll output to stdout.`)
.option('-o, --output <directory>', `Output to a folder.`)
.option('-m, --max-array-length <number>', `Max array length, default to 20.`)
.option('-t, --includes <keywords>', `Include the request path with given string, can be seperated with comma.`)
.option('-e, --excludes <keywords>', `Exclude the request path with given string, can be seperated with comma.`)
.option('--base-url [baseUrl]', `Use the one you specified or server url in OpenAPI description as base url.`)
.option('--static', 'By default it will generate dynamic mocks, use this flag if you want generate static mocks.')
.option(
'--node',
`By default it will generate code for browser environment, use this flag if you want to use it in Node.js environment.`
)
.option(
'--react-native',
`By default it will generate code for browser environment, use this flag if you want to use it in React Native environment. Additionally you will need to add polyfills to patch the global environment by installing react-native-url-polyfill.`
)
.option('-c, --codes <keywords>', 'Comma separated list of status codes to generate responses for')
.example('msw-auto-mock ./githubapi.yaml -o mock.js')
.example('msw-auto-mock ./githubapi.yaml -o mock.js -t /admin,/repo -m 30')
Expand Down
94 changes: 60 additions & 34 deletions src/generate.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,66 @@
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path';

import ApiGenerator, { isReference } from 'oazapfts/lib/codegen/generate';
import ApiGenerator, { isReference } from 'oazapfts/generate';
import { OpenAPIV3 } from 'openapi-types';
import camelCase from 'lodash/camelCase';
import { cosmiconfig } from 'cosmiconfig';

import { getV3Doc } from './swagger';
import { prettify, toExpressLikePath } from './utils';
import { Operation } from './transform';
import { mockTemplate } from './template';
import { CliOptions } from './types';
import { browserIntegration, mockTemplate, nodeIntegration, reactNativeIntegration } from './template';
import { CliOptions, ConfigOptions } from './types';
import { name as moduleName } from '../package.json';

export function generateOperationCollection(apiDoc: OpenAPIV3.Document, options: CliOptions) {
const apiGen = new ApiGenerator(apiDoc, {});

const operationDefinitions = getOperationDefinitions(apiDoc);

return operationDefinitions
.filter(op => operationFilter(op, options))
.map(op => codeFilter(op, options))
.map(definition => toOperation(definition, apiGen));
}

export async function generate(spec: string, options: CliOptions) {
const { output: outputFile } = options;
export async function generate(spec: string, inlineOptions: CliOptions) {
const explorer = cosmiconfig(moduleName);
const finalOptions: ConfigOptions = { ...inlineOptions };

try {
const result = await explorer.search();
if (!result?.isEmpty) {
Object.assign(finalOptions, result?.config);
}
} catch (e) {
console.log(e);
process.exit(1);
}

const { output: outputFolder } = finalOptions;
const targetFolder = path.resolve(process.cwd(), outputFolder);

let code: string;
const apiDoc = await getV3Doc(spec);

const operationCollection = generateOperationCollection(apiDoc, options);
const operationCollection = generateOperationCollection(apiDoc, finalOptions);

let baseURL = '';
if (options.baseUrl === true) {
if (finalOptions.baseUrl === true) {
baseURL = getServerUrl(apiDoc);
} else if (typeof options.baseUrl === 'string') {
baseURL = options.baseUrl;
} else if (typeof finalOptions.baseUrl === 'string') {
baseURL = finalOptions.baseUrl;
}
code = mockTemplate(operationCollection, baseURL, options);

if (outputFile) {
fs.writeFileSync(path.resolve(process.cwd(), outputFile), await prettify(outputFile, code));
} else {
console.log(await prettify(null, code));
}
code = mockTemplate(operationCollection, baseURL, finalOptions);

try {
fs.mkdirSync(targetFolder);
} catch {}

fs.writeFileSync(path.resolve(process.cwd(), targetFolder, 'native.js'), reactNativeIntegration);
fs.writeFileSync(path.resolve(process.cwd(), targetFolder, 'node.js'), nodeIntegration);
fs.writeFileSync(path.resolve(process.cwd(), targetFolder, 'browser.js'), browserIntegration);
fs.writeFileSync(path.resolve(process.cwd(), targetFolder, 'handlers.js'), await prettify('handlers.js', code));
}

function getServerUrl(apiDoc: OpenAPIV3.Document) {
Expand Down Expand Up @@ -81,7 +101,7 @@ function getOperationDefinitions(v3Doc: OpenAPIV3.Document): OperationDefinition
id,
responses: operation.responses,
};
})
}),
);
}

Expand All @@ -92,7 +112,7 @@ function operationFilter(operation: OperationDefinition, options: CliOptions): b
if (includes && !includes.includes(operation.path)) {
return false;
}
if (excludes && excludes.includes(operation.path)) {
if (excludes?.includes(operation.path)) {
return false;
}
return true;
Expand All @@ -111,7 +131,7 @@ function codeFilter(operation: OperationDefinition, options: CliOptions): Operat
.map(([code, response]) => ({
[code]: response,
}))
.reduce((acc, curr) => ({ ...acc, ...curr }), {} as OpenAPIV3.ResponsesObject);
.reduce((acc, curr) => Object.assign(acc, curr), {} as OpenAPIV3.ResponsesObject);

return {
...operation,
Expand All @@ -128,14 +148,17 @@ function toOperation(definition: OperationDefinition, apiGen: ApiGenerator): Ope
return { code, id: '', responses: {} };
}

const resolvedResponse = Object.keys(content).reduce((resolved, type) => {
const schema = content[type].schema;
if (typeof schema !== 'undefined') {
resolved[type] = recursiveResolveSchema(schema, apiGen);
}
const resolvedResponse = Object.keys(content).reduce(
(resolved, type) => {
const schema = content[type].schema;
if (typeof schema !== 'undefined') {
resolved[type] = recursiveResolveSchema(schema, apiGen);
}

return resolved;
}, {} as Record<string, OpenAPIV3.SchemaObject>);
return resolved;
},
{} as Record<string, OpenAPIV3.SchemaObject>,
);

return {
code,
Expand Down Expand Up @@ -183,16 +206,19 @@ function recursiveResolveSchema(schema: OpenAPIV3.ReferenceObject | OpenAPIV3.Sc
if (isReference(resolvedSchema.additionalProperties)) {
resolvedSchema.additionalProperties = recursiveResolveSchema(
resolve(resolvedSchema.additionalProperties, apiGen),
apiGen
apiGen,
);
}
}

if (resolvedSchema.properties) {
resolvedSchema.properties = Object.entries(resolvedSchema.properties).reduce((resolved, [key, value]) => {
resolved[key] = recursiveResolveSchema(value, apiGen);
return resolved;
}, {} as Record<string, OpenAPIV3.SchemaObject>);
resolvedSchema.properties = Object.entries(resolvedSchema.properties).reduce(
(resolved, [key, value]) => {
resolved[key] = recursiveResolveSchema(value, apiGen);
return resolved;
},
{} as Record<string, OpenAPIV3.SchemaObject>,
);
}
} else if (resolvedSchema.allOf) {
resolvedSchema.allOf = resolvedSchema.allOf.map(item => recursiveResolveSchema(item, apiGen));
Expand Down
109 changes: 86 additions & 23 deletions src/template.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,23 @@
import { CliOptions } from './types';
import { CliOptions, ConfigOptions } from './types';
import { OperationCollection, transformToHandlerCode, transformToGenerateResultFunctions } from './transform';
import { match } from 'ts-pattern';

const getSetupCode = (options?: CliOptions) => {
if (options?.node || options?.reactNative) {
return [`const server = setupServer(...handlers);`, `server.listen();`].join('\n');
}

return [`const worker = setupWorker(...handlers);`, `worker.start();`].join('\n');
};

const getImportsCode = (options?: CliOptions) => {
const getImportsCode = () => {
const imports = [`import { HttpResponse, http } from 'msw';`, `import { faker } from '@faker-js/faker';`];

if (options?.node) {
imports.push(`import { setupServer } from 'msw/node'`);
} else if (options?.reactNative) {
imports.push(`import { setupServer } from 'msw/native'`);
} else {
imports.push(`import { setupWorker } from 'msw/browser'`);
}

return imports.join('\n');
};

export const mockTemplate = (operationCollection: OperationCollection, baseURL: string, options?: CliOptions) => `/**
export const mockTemplate = (operationCollection: OperationCollection, baseURL: string, options: ConfigOptions) => `/**
* This file is AUTO GENERATED by [msw-auto-mock](https://github.com/zoubingwu/msw-auto-mock)
* Feel free to commit/edit it as you need.
*/
/* eslint-disable */
/* tslint:disable */
${getImportsCode(options)}
${getImportsCode()}
${createAiGenerateText(options)}
${withCreatePrompt}
faker.seed(1);
Expand All @@ -49,9 +37,84 @@ export const handlers = [
];
${transformToGenerateResultFunctions(operationCollection, baseURL, options)}
`;

export const browserIntegration = [
`import { setupWorker } from 'msw/browser'`,
`import { handlers } from './handlers'`,
`export const worker = setupWorker(...handlers)`,
].join('\n');

export const nodeIntegration = [
`import { setupServer } from 'msw/node'`,
`import { handlers } from './handlers'`,
`export const server = setupServer(...handlers)`,
].join(`\n`);

export const reactNativeIntegration = [
`import { setupServer } from 'msw/native'`,
`import { handlers } from './handlers'`,
`export const server = setupServer(...handlers)`,
].join(`\n`);

const askOpenai = (options: ConfigOptions) => `
import { createOpenAI } from '@ai-sdk/openai';
import { generateText } from 'ai';
async function ask(operation) {
const { text } = await generateText({
model: createOpenAI({
apiKey: ${options.ai?.openai?.apiKey},
baseURL: ${options.ai?.openai?.apiKey},
})(${options.ai?.openai?.model}),
prompt: createPrompt(operation),
});
// This configures a Service Worker with the given request handlers.
export const startWorker = () => {
${getSetupCode(options)}
return JSON.parse(text);
}
`;

const askAzure = (options: ConfigOptions) => `
import { createAzure } from '@ai-sdk/azure';
import { generateText } from 'ai';
async function ask(operation) {
const { text } = await generateText({
model: createAzure({
resourceName: ${options.ai?.azure?.resource},
apiKey: ${options.ai?.azure?.apiKey}
})(${options.ai?.azure?.deployment}),
prompt: createPrompt(operation),
});
return JSON.parse(text);
}
`;

const askAnthropic = (options: ConfigOptions) => `
import { createAnthropic } from '@ai-sdk/anthropic';
import { generateText } from 'ai';
async function ask(operation) {
const { text } = await generateText({
model: createAnthropic({
apiKey: ${options.ai?.anthropic?.apiKey}
})(${options.ai?.anthropic?.model}),
prompt: createPrompt(operation),
});
return JSON.parse(text);
}
`;

const withCreatePrompt = `
function createPrompt(operation) {
return "Given the following Swagger (OpenAPI) definition, generate a mock data object that conforms to the specified response structure, ensuring each property adheres to its defined constraints, especially type, format, example, description, enum values, ignore the xml field. The generated JSON string should include all the properties defined in the Swagger schema as much as possible and the values should be valid based on the property definitions (e.g., integer, string, length etc.) and rules (e.g, int64 should be string in json etc.). Please only return the JSON string as the output, don't wrap it with markdown. The definition is like below: \\n" + "\`\`\`json" + JSON.stringify(operation, null, 4) + "\\n\`\`\`";
}
`;

export function createAiGenerateText(options: ConfigOptions): string {
return match(options.ai?.provider)
.with('openai', () => askOpenai(options))
.with('azure', () => askAzure(options))
.with('anthropic', () => askAnthropic(options))
.otherwise(() => '');
}
Loading

0 comments on commit f99d8a3

Please sign in to comment.