@ply-ct/ply
Version:
REST API Automated Testing
313 lines (292 loc) • 13 kB
text/typescript
import * as ts from 'typescript';
import { Ply } from '../ply';
import { Request } from '../request';
import { FlowSuite } from '../flows';
import { Suite } from '../suite';
import { Ts } from '../ts';
import * as yaml from '../yaml';
import { EndpointMethod } from './plyex';
export interface MethodMeta {
name: string;
summary: string;
description?: string;
}
/**
* Raw info as parsed from plyex JSDoc tag.
*/
export interface PlyMeta {
request?: string;
responses?: { [key: string]: string[] };
}
export interface PlyEndpointMeta {
operationId?: string;
summaries: string[];
description?: string;
plyMeta?: PlyMeta;
examples?: {
request?: string;
responses?: { [key: string]: string[] };
};
}
export class PlyExampleRequest {
readonly suitePath: string;
readonly requestName: string;
constructor(requestPath: string, readonly options?: { samplesFromActual?: boolean }) {
const hash = requestPath.lastIndexOf('#');
if (hash === -1 || hash > requestPath.length - 1) {
throw new Error(`Ply example path must include '#<requestName>': ${requestPath}`);
}
this.suitePath = requestPath.substring(0, hash);
this.requestName = requestPath.substring(hash + 1);
}
async getSuite(): Promise<Suite<Request> | FlowSuite> {
let suite: Suite<Request> | FlowSuite;
if (this.suitePath.endsWith('.flow')) {
suite = PlyExampleRequest.flowSuites.get(this.suitePath);
if (!suite) {
suite = await new Ply().loadFlow(this.suitePath);
PlyExampleRequest.requestSuites.set(this.suitePath, suite);
}
} else {
suite = PlyExampleRequest.requestSuites.get(this.suitePath);
if (!suite) {
suite = await new Ply().loadRequestSuite(this.suitePath);
PlyExampleRequest.requestSuites.set(this.suitePath, suite);
}
}
return suite;
}
async getExpected(): Promise<any> {
let expectedObj = PlyExampleRequest.expectedObjs.get(this.suitePath);
if (!expectedObj) {
const suite = await this.getSuite();
const expected = suite.runtime.results.expected;
const contents = expected.storage?.read();
if (!contents) throw new Error(`Expected results not found: ${expected.storage}`);
expectedObj = yaml.load('' + expected.storage, contents);
if (this.suitePath.endsWith('.flow')) {
expectedObj = Object.keys(expectedObj).reduce((obj, key) => {
const step = expectedObj[key];
if (step.id && step.request && step.response) {
obj[step.id] = {
id: step.id,
request: step.request,
response: step.response
};
}
return obj;
}, {} as any);
}
PlyExampleRequest.expectedObjs.set(this.suitePath, expectedObj);
}
return expectedObj;
}
async getActual(): Promise<any> {
let actualObj = PlyExampleRequest.actualObjs.get(this.suitePath);
if (!actualObj) {
const suite = await this.getSuite();
const actual = suite.runtime.results.actual;
const contents = actual.read();
if (!contents) throw new Error(`Actual results not found: ${actual}`);
actualObj = yaml.load('' + actual, contents);
if (this.suitePath.endsWith('.flow')) {
actualObj = Object.keys(actualObj).reduce((obj, key) => {
const step = actualObj[key];
if (step.id && step.request && step.response) {
obj[step.id] = {
id: step.id,
request: step.request,
response: step.response
};
}
return obj;
}, {} as any);
}
PlyExampleRequest.actualObjs.set(this.suitePath, actualObj);
}
return actualObj;
}
async getExampleRequest(): Promise<string | undefined> {
if (this.options?.samplesFromActual) {
const actual = await this.getActual();
return actual[this.requestName]?.request?.body;
} else {
const expected = await this.getExpected();
return expected[this.requestName]?.request?.body;
}
}
async getExampleResponse(): Promise<string | undefined> {
if (this.options?.samplesFromActual) {
const actual = await this.getActual();
return actual[this.requestName]?.response?.body;
} else {
const expected = await this.getExpected();
return expected[this.requestName]?.response?.body;
}
}
private static requestSuites = new Map<string, any>();
private static flowSuites = new Map<string, any>();
private static expectedObjs = new Map<string, any>();
private static actualObjs = new Map<string, any>();
}
export class JsDocReader {
constructor(private ts: Ts, readonly sourceFile: ts.SourceFile) {}
async getPlyEndpointMeta(
endpointMethod: EndpointMethod,
tag = 'ply',
untaggedMethods = false,
samplesFromActual?: boolean
): Promise<PlyEndpointMeta | undefined> {
const classDecl = this.ts.getClassDeclaration(
this.sourceFile.fileName,
endpointMethod.class
);
if (classDecl) {
const methodDecl = Ts.methodDeclarations(classDecl).find(
(md) => md.name.getText() === endpointMethod.name
);
if (methodDecl) {
const methodMeta = this.findMethodMeta(methodDecl);
if (methodMeta) {
const plyMeta = this.readPlyMeta(endpointMethod.class, methodDecl, tag);
if (plyMeta || untaggedMethods) {
const plyEndpointMeta: PlyEndpointMeta = {
summaries: [methodMeta.summary]
};
// @operationId tag
const operationId = this.readStringMeta(methodDecl, 'operationId');
if (operationId) plyEndpointMeta.operationId = operationId;
const pipe = methodMeta.summary.indexOf('|');
if (pipe > 0 && pipe < methodMeta.summary.length - 1) {
plyEndpointMeta.summaries = [
methodMeta.summary.substring(0, pipe).trim(),
methodMeta.summary.substring(pipe + 1).trim()
];
}
if (methodMeta.description) {
plyEndpointMeta.description = methodMeta.description;
}
// @ply tag
let exampleRequest: string | undefined;
if (plyMeta?.request) {
exampleRequest = await new PlyExampleRequest(plyMeta.request, {
samplesFromActual
}).getExampleRequest();
}
let exampleResponses: { [key: string]: string[] } | undefined;
if (plyMeta?.responses) {
for (const key of Object.keys(plyMeta.responses)) {
const responses = plyMeta.responses[key];
for (const response of responses) {
const exampleResponse = await new PlyExampleRequest(response, {
samplesFromActual
}).getExampleResponse();
if (exampleResponse) {
if (!exampleResponses) {
exampleResponses = {};
}
if (!exampleResponses['' + key]) {
exampleResponses['' + key] = [];
}
exampleResponses['' + key].push(exampleResponse);
}
}
}
}
if (exampleRequest || exampleResponses) {
plyEndpointMeta.examples = {
...(exampleRequest && { request: exampleRequest }),
...(exampleResponses && { responses: exampleResponses })
};
}
if (
(endpointMethod.method === 'post' ||
endpointMethod.method === 'put' ||
endpointMethod.method === 'patch') &&
!plyEndpointMeta.examples?.request
) {
console.error(
`** Warning: No @${tag} sample request for: ${endpointMethod.class}.${endpointMethod.name}()`
);
}
if (Object.keys(plyEndpointMeta.examples?.responses || {}).length === 0) {
console.error(
`** Warning: No @${tag} sample responses for: ${endpointMethod.class}.${endpointMethod.name}()`
);
}
return plyEndpointMeta;
}
}
}
}
}
/**
* Summary is first line or sentence of a JSDoc comment, if any.
* Description is the remainder.
*/
findMethodMeta(methodDeclaration: ts.MethodDeclaration): MethodMeta | undefined {
const symbol = Ts.symbolAtNode(methodDeclaration);
const docComment = symbol?.getDocumentationComment(this.ts.checker);
if (docComment?.length && docComment[0].kind === 'text' && docComment[0].text) {
const lines = docComment[0].text.split(/\r?\n/);
const dot = lines[0].indexOf('.');
const meta: MethodMeta = {
name: methodDeclaration.name.getText(),
summary: (dot > 0 ? lines[0].substring(0, dot) : lines[0]).trim()
};
let descrip =
dot > 0 && dot < lines[0].length + 1 ? lines[0].substring(dot + 1).trim() : '';
for (let i = 1; i < lines.length; i++) {
descrip += `\n${lines[i].trim()}`;
}
if (descrip.length) meta.description = descrip.trim();
return meta;
}
}
readStringMeta(methodDeclaration: ts.MethodDeclaration, tag: string): string | undefined {
const symbol = Ts.symbolAtNode(methodDeclaration);
const jsDocTag = symbol?.getJsDocTags()?.find((t) => t.name === tag);
if (jsDocTag?.text) {
let tagText: any = jsDocTag.text;
if (Array.isArray(tagText)) {
// depends on typescript version
tagText = tagText[0].text;
}
return tagText;
}
}
readPlyMeta(
className: string,
methodDeclaration: ts.MethodDeclaration,
tag: string
): PlyMeta | undefined {
const symbol = Ts.symbolAtNode(methodDeclaration);
const plyJsDocTag = symbol?.getJsDocTags()?.find((t) => t.name === tag);
if (plyJsDocTag?.text) {
let tagText: any = plyJsDocTag.text;
if (Array.isArray(tagText)) {
// depends on typescript version
tagText = tagText[0].text;
}
try {
const plyMeta: PlyMeta = yaml.load(
`${methodDeclaration.name.getText()}: @${tag}`,
`${tagText}\n`
);
if (plyMeta.responses) {
for (const code of Object.keys(plyMeta.responses)) {
if (typeof plyMeta.responses[code] === 'string') {
plyMeta.responses[code] = ['' + plyMeta.responses[code]];
}
}
}
return plyMeta;
} catch (err: unknown) {
console.debug(`${err}`, err);
throw new Error(
`Failed to parse @${tag} tag for ${className}.${symbol?.name}():\n${err}`
);
}
}
}
}