From b00041db3a6590ded96d9fa86fe78f44f68d3f13 Mon Sep 17 00:00:00 2001 From: rentu <5545529+SLdragon@users.noreply.github.com> Date: Thu, 7 Mar 2024 10:19:50 +0800 Subject: [PATCH] perf(spec-parser): update to support type b ai-plugin (#10972) * perf(spec-parser): update to support ai-plugin * perf: remove test code * perf(spec-parser): update test cases to make codecov happy * perf: refactor code * perf: fix according to the comments * perf: update code for browser * perf: update plugin file name to path, remove state --------- Co-authored-by: turenlong --- packages/spec-parser/package.json | 2 +- packages/spec-parser/src/constants.ts | 2 + packages/spec-parser/src/interfaces.ts | 1 + packages/spec-parser/src/manifestUpdater.ts | 186 ++++- packages/spec-parser/src/specFilter.ts | 6 +- .../spec-parser/src/specParser.browser.ts | 24 +- packages/spec-parser/src/specParser.ts | 65 +- packages/spec-parser/src/utils.ts | 90 +- .../test/browser/specParser.browser.test.ts | 19 + .../spec-parser/test/manifestUpdater.test.ts | 624 ++++++++++++++ packages/spec-parser/test/specFilter.test.ts | 11 +- packages/spec-parser/test/specParser.test.ts | 210 +++++ packages/spec-parser/test/utils.test.ts | 780 +++++++++++++++++- 13 files changed, 1942 insertions(+), 78 deletions(-) diff --git a/packages/spec-parser/package.json b/packages/spec-parser/package.json index 20451f04db..ea8886f13c 100644 --- a/packages/spec-parser/package.json +++ b/packages/spec-parser/package.json @@ -9,7 +9,7 @@ "types": "dist/src/index.d.ts", "scripts": { "build": "rollup -c", - "test:unit:node": "nyc --no-clean -- mocha \"test/*.test.ts\" -r config/mocha.env.ts --config config/.mocharc.json", + "test:unit:node": "nyc --no-clean -- mocha -r config/mocha.env.ts --config config/.mocharc.json", "test:unit:browser": "karma start karma.conf.cjs --single-run --unit", "test:unit": "npm run test:unit:node && npm run test:unit:browser ", "lint:staged": "lint-staged", diff --git a/packages/spec-parser/src/constants.ts b/packages/spec-parser/src/constants.ts index 62d43a247b..ca8c863059 100644 --- a/packages/spec-parser/src/constants.ts +++ b/packages/spec-parser/src/constants.ts @@ -33,6 +33,8 @@ export class ConstantString { static readonly MultipleAPIKeyNotSupported = "Multiple API keys are not supported. Please make sure that all selected APIs use the same API key."; + static readonly UnsupportedSchema = "Unsupported schema in %s %s: %s"; + static readonly WrappedCardVersion = "devPreview"; static readonly WrappedCardSchema = "https://developer.microsoft.com/json-schemas/teams/vDevPreview/MicrosoftTeams.ResponseRenderingTemplate.schema.json"; diff --git a/packages/spec-parser/src/interfaces.ts b/packages/spec-parser/src/interfaces.ts index 4ec23b7fd1..5de4809cd7 100644 --- a/packages/spec-parser/src/interfaces.ts +++ b/packages/spec-parser/src/interfaces.ts @@ -187,6 +187,7 @@ export interface ParseOptions { allowAPIKeyAuth?: boolean; allowMultipleParameters?: boolean; allowOauth2?: boolean; + isCopilot?: boolean; } export interface APIInfo { diff --git a/packages/spec-parser/src/manifestUpdater.ts b/packages/spec-parser/src/manifestUpdater.ts index 83bd4623b4..fbf34a6bb0 100644 --- a/packages/spec-parser/src/manifestUpdater.ts +++ b/packages/spec-parser/src/manifestUpdater.ts @@ -13,9 +13,185 @@ import { IComposeExtension, IMessagingExtensionCommand, TeamsAppManifest, + PluginManifestSchema, + FunctionObject, + FunctionParameters, + FunctionParameter, } from "@microsoft/teams-manifest"; export class ManifestUpdater { + static async updateManifestWithAiPlugin( + manifestPath: string, + outputSpecPath: string, + apiPluginFilePath: string, + spec: OpenAPIV3.Document + ): Promise<[TeamsAppManifest, PluginManifestSchema]> { + const manifest: TeamsAppManifest = await fs.readJSON(manifestPath); + const apiPluginRelativePath = ManifestUpdater.getRelativePath(manifestPath, apiPluginFilePath); + manifest.apiPlugins = [ + { + pluginFile: apiPluginRelativePath, + }, + ]; + + ManifestUpdater.updateManifestDescription(manifest, spec); + + const specRelativePath = ManifestUpdater.getRelativePath(manifestPath, outputSpecPath); + const apiPlugin = ManifestUpdater.generatePluginManifestSchema(spec, specRelativePath); + + return [manifest, apiPlugin]; + } + + static updateManifestDescription(manifest: TeamsAppManifest, spec: OpenAPIV3.Document): void { + manifest.description = { + short: spec.info.title.slice(0, ConstantString.ShortDescriptionMaxLens), + full: (spec.info.description ?? manifest.description.full)?.slice( + 0, + ConstantString.FullDescriptionMaxLens + ), + }; + } + + static mapOpenAPISchemaToFuncParam( + schema: OpenAPIV3.SchemaObject, + method: string, + pathUrl: string + ): FunctionParameter { + let parameter: FunctionParameter; + if ( + schema.type === "string" || + schema.type === "boolean" || + schema.type === "integer" || + schema.type === "number" || + schema.type === "array" + ) { + parameter = schema as any; + } else { + throw new SpecParserError( + Utils.format(ConstantString.UnsupportedSchema, method, pathUrl, JSON.stringify(schema)), + ErrorType.UpdateManifestFailed + ); + } + + return parameter; + } + + static generatePluginManifestSchema( + spec: OpenAPIV3.Document, + specRelativePath: string + ): PluginManifestSchema { + const functions: FunctionObject[] = []; + const functionNames: string[] = []; + + const paths = spec.paths; + + for (const pathUrl in paths) { + const pathItem = paths[pathUrl]; + if (pathItem) { + const operations = pathItem; + for (const method in operations) { + if (ConstantString.AllOperationMethods.includes(method)) { + const operationItem = (operations as any)[method] as OpenAPIV3.OperationObject; + if (operationItem) { + const operationId = operationItem.operationId!; + const description = operationItem.description ?? ""; + const paramObject = operationItem.parameters as OpenAPIV3.ParameterObject[]; + const requestBody = operationItem.requestBody as OpenAPIV3.ParameterObject; + + const parameters: FunctionParameters = { + type: "object", + properties: {}, + required: [], + }; + + if (paramObject) { + for (let i = 0; i < paramObject.length; i++) { + const param = paramObject[i]; + + const schema = param.schema as OpenAPIV3.SchemaObject; + + parameters.properties![param.name] = ManifestUpdater.mapOpenAPISchemaToFuncParam( + schema, + method, + pathUrl + ); + + if (param.required) { + parameters.required!.push(param.name); + } + + if (!parameters.properties![param.name].description) { + parameters.properties![param.name].description = param.description ?? ""; + } + } + } + + if (requestBody) { + const requestJsonBody = requestBody.content!["application/json"]; + const requestBodySchema = requestJsonBody.schema as OpenAPIV3.SchemaObject; + + if (requestBodySchema.type === "object") { + if (requestBodySchema.required) { + parameters.required!.push(...requestBodySchema.required); + } + + for (const property in requestBodySchema.properties) { + const schema = requestBodySchema.properties[property] as OpenAPIV3.SchemaObject; + parameters.properties![property] = ManifestUpdater.mapOpenAPISchemaToFuncParam( + schema, + method, + pathUrl + ); + } + } else { + throw new SpecParserError( + Utils.format( + ConstantString.UnsupportedSchema, + method, + pathUrl, + JSON.stringify(requestBodySchema) + ), + ErrorType.UpdateManifestFailed + ); + } + } + + const funcObj: FunctionObject = { + name: operationId, + description: description, + parameters: parameters, + }; + + functions.push(funcObj); + functionNames.push(operationId); + } + } + } + } + } + + const apiPlugin: PluginManifestSchema = { + schema_version: "v2", + name_for_human: spec.info.title, + description_for_human: spec.info.description ?? "", + functions: functions, + runtimes: [ + { + type: "OpenApi", + auth: { + type: "none", // TODO, support auth in the future + }, + spec: { + url: specRelativePath, + }, + run_for_functions: functionNames, + }, + ], + }; + + return apiPlugin; + } + static async updateManifest( manifestPath: string, outputSpecPath: string, @@ -67,14 +243,8 @@ export class ManifestUpdater { } } - updatedPart.description = { - short: spec.info.title.slice(0, ConstantString.ShortDescriptionMaxLens), - full: (spec.info.description ?? originalManifest.description.full)?.slice( - 0, - ConstantString.FullDescriptionMaxLens - ), - }; - + updatedPart.description = originalManifest.description; + ManifestUpdater.updateManifestDescription(updatedPart, spec); updatedPart.composeExtensions = isMe === undefined || isMe === true ? [composeExtension] : []; const updatedManifest = { ...originalManifest, ...updatedPart }; diff --git a/packages/spec-parser/src/specFilter.ts b/packages/spec-parser/src/specFilter.ts index b59b42a5a8..2e86599019 100644 --- a/packages/spec-parser/src/specFilter.ts +++ b/packages/spec-parser/src/specFilter.ts @@ -16,7 +16,8 @@ export class SpecFilter { allowMissingId: boolean, allowAPIKeyAuth: boolean, allowMultipleParameters: boolean, - allowOauth2: boolean + allowOauth2: boolean, + isCopilot: boolean ): OpenAPIV3.Document { try { const newSpec = { ...unResolveSpec }; @@ -33,7 +34,8 @@ export class SpecFilter { allowMissingId, allowAPIKeyAuth, allowMultipleParameters, - allowOauth2 + allowOauth2, + isCopilot ) ) { continue; diff --git a/packages/spec-parser/src/specParser.browser.ts b/packages/spec-parser/src/specParser.browser.ts index a4edf2e588..b29f632930 100644 --- a/packages/spec-parser/src/specParser.browser.ts +++ b/packages/spec-parser/src/specParser.browser.ts @@ -37,6 +37,7 @@ export class SpecParser { allowAPIKeyAuth: false, allowMultipleParameters: false, allowOauth2: false, + isCopilot: false, }; /** @@ -92,7 +93,8 @@ export class SpecParser { this.options.allowMissingId, this.options.allowAPIKeyAuth, this.options.allowMultipleParameters, - this.options.allowOauth2 + this.options.allowOauth2, + this.options.isCopilot ); } catch (err) { throw new SpecParserError((err as Error).toString(), ErrorType.ValidateFailed); @@ -163,6 +165,23 @@ export class SpecParser { throw new Error("Method not implemented."); } + /** + * Generates and update artifacts from the OpenAPI specification file. Generate Adaptive Cards, update Teams app manifest, and generate a new OpenAPI specification file. + * @param manifestPath A file path of the Teams app manifest file to update. + * @param filter An array of strings that represent the filters to apply when generating the artifacts. If filter is empty, it would process nothing. + * @param outputSpecPath File path of the new OpenAPI specification file to generate. If not specified or empty, no spec file will be generated. + * @param pluginFilePath File path of the api plugin file to generate. + */ + // eslint-disable-next-line @typescript-eslint/require-await + async generateForCopilot( + manifestPath: string, + filter: string[], + outputSpecPath: string, + pluginFilePath: string, + signal?: AbortSignal + ): Promise { + throw new Error("Method not implemented."); + } /** * Generates and update artifacts from the OpenAPI specification file. Generate Adaptive Cards, update Teams app manifest, and generate a new OpenAPI specification file. * @param manifestPath A file path of the Teams app manifest file to update. @@ -206,7 +225,8 @@ export class SpecParser { this.options.allowMissingId, this.options.allowAPIKeyAuth, this.options.allowMultipleParameters, - this.options.allowOauth2 + this.options.allowOauth2, + this.options.isCopilot ); this.apiMap = result; return result; diff --git a/packages/spec-parser/src/specParser.ts b/packages/spec-parser/src/specParser.ts index 0ee2dbca4f..4a830fbb2e 100644 --- a/packages/spec-parser/src/specParser.ts +++ b/packages/spec-parser/src/specParser.ts @@ -45,6 +45,7 @@ export class SpecParser { allowAPIKeyAuth: false, allowMultipleParameters: false, allowOauth2: false, + isCopilot: false, }; /** @@ -96,7 +97,8 @@ export class SpecParser { this.options.allowMissingId, this.options.allowAPIKeyAuth, this.options.allowMultipleParameters, - this.options.allowOauth2 + this.options.allowOauth2, + this.options.isCopilot ); } catch (err) { throw new SpecParserError((err as Error).toString(), ErrorType.ValidateFailed); @@ -194,7 +196,8 @@ export class SpecParser { this.options.allowMissingId, this.options.allowAPIKeyAuth, this.options.allowMultipleParameters, - this.options.allowOauth2 + this.options.allowOauth2, + this.options.isCopilot ); if (signal?.aborted) { @@ -211,6 +214,61 @@ export class SpecParser { } } + /** + * Generates and update artifacts from the OpenAPI specification file. Generate Adaptive Cards, update Teams app manifest, and generate a new OpenAPI specification file. + * @param manifestPath A file path of the Teams app manifest file to update. + * @param filter An array of strings that represent the filters to apply when generating the artifacts. If filter is empty, it would process nothing. + * @param outputSpecPath File path of the new OpenAPI specification file to generate. If not specified or empty, no spec file will be generated. + * @param pluginFilePath File path of the api plugin file to generate. + */ + async generateForCopilot( + manifestPath: string, + filter: string[], + outputSpecPath: string, + pluginFilePath: string, + signal?: AbortSignal + ): Promise { + const result: GenerateResult = { + allSuccess: true, + warnings: [], + }; + + try { + const newSpecs = await this.getFilteredSpecs(filter, signal); + const newUnResolvedSpec = newSpecs[0]; + const newSpec = newSpecs[1]; + + let resultStr; + if (outputSpecPath.endsWith(".yaml") || outputSpecPath.endsWith(".yml")) { + resultStr = jsyaml.dump(newUnResolvedSpec); + } else { + resultStr = JSON.stringify(newUnResolvedSpec, null, 2); + } + await fs.outputFile(outputSpecPath, resultStr); + + if (signal?.aborted) { + throw new SpecParserError(ConstantString.CancelledMessage, ErrorType.Cancelled); + } + + const [updatedManifest, apiPlugin] = await ManifestUpdater.updateManifestWithAiPlugin( + manifestPath, + outputSpecPath, + pluginFilePath, + newSpec + ); + + await fs.outputJSON(manifestPath, updatedManifest, { spaces: 2 }); + await fs.outputJSON(pluginFilePath, apiPlugin, { spaces: 2 }); + } catch (err) { + if (err instanceof SpecParserError) { + throw err; + } + throw new SpecParserError((err as Error).toString(), ErrorType.GenerateFailed); + } + + return result; + } + /** * Generates and update artifacts from the OpenAPI specification file. Generate Adaptive Cards, update Teams app manifest, and generate a new OpenAPI specification file. * @param manifestPath A file path of the Teams app manifest file to update. @@ -351,7 +409,8 @@ export class SpecParser { this.options.allowMissingId, this.options.allowAPIKeyAuth, this.options.allowMultipleParameters, - this.options.allowOauth2 + this.options.allowOauth2, + this.options.isCopilot ); this.apiMap = result; return result; diff --git a/packages/spec-parser/src/utils.ts b/packages/spec-parser/src/utils.ts index db6ac3321a..72c26e9cfe 100644 --- a/packages/spec-parser/src/utils.ts +++ b/packages/spec-parser/src/utils.ts @@ -19,7 +19,22 @@ import { import { IMessagingExtensionCommand } from "@microsoft/teams-manifest"; export class Utils { - static checkParameters(paramObject: OpenAPIV3.ParameterObject[]): CheckParamResult { + static hasNestedObjectInSchema(schema: OpenAPIV3.SchemaObject): boolean { + if (schema.type === "object") { + for (const property in schema.properties) { + const nestedSchema = schema.properties[property] as OpenAPIV3.SchemaObject; + if (nestedSchema.type === "object") { + return true; + } + } + } + return false; + } + + static checkParameters( + paramObject: OpenAPIV3.ParameterObject[], + isCopilot: boolean + ): CheckParamResult { const paramResult = { requiredNum: 0, optionalNum: 0, @@ -33,8 +48,23 @@ export class Utils { for (let i = 0; i < paramObject.length; i++) { const param = paramObject[i]; const schema = param.schema as OpenAPIV3.SchemaObject; + + if (isCopilot && this.hasNestedObjectInSchema(schema)) { + paramResult.isValid = false; + continue; + } + const isRequiredWithoutDefault = param.required && schema.default === undefined; + if (isCopilot) { + if (isRequiredWithoutDefault) { + paramResult.requiredNum = paramResult.requiredNum + 1; + } else { + paramResult.optionalNum = paramResult.optionalNum + 1; + } + continue; + } + if (param.in === "header" || param.in === "cookie") { if (isRequiredWithoutDefault) { paramResult.isValid = false; @@ -66,7 +96,11 @@ export class Utils { return paramResult; } - static checkPostBody(schema: OpenAPIV3.SchemaObject, isRequired = false): CheckParamResult { + static checkPostBody( + schema: OpenAPIV3.SchemaObject, + isRequired = false, + isCopilot = false + ): CheckParamResult { const paramResult = { requiredNum: 0, optionalNum: 0, @@ -79,6 +113,11 @@ export class Utils { const isRequiredWithoutDefault = isRequired && schema.default === undefined; + if (isCopilot && this.hasNestedObjectInSchema(schema)) { + paramResult.isValid = false; + return paramResult; + } + if ( schema.type === "string" || schema.type === "integer" || @@ -99,14 +138,15 @@ export class Utils { } const result = Utils.checkPostBody( properties[property] as OpenAPIV3.SchemaObject, - isRequired + isRequired, + isCopilot ); paramResult.requiredNum += result.requiredNum; paramResult.optionalNum += result.optionalNum; paramResult.isValid = paramResult.isValid && result.isValid; } } else { - if (isRequiredWithoutDefault) { + if (isRequiredWithoutDefault && !isCopilot) { paramResult.isValid = false; } } @@ -134,7 +174,8 @@ export class Utils { allowMissingId: boolean, allowAPIKeyAuth: boolean, allowMultipleParameters: boolean, - allowOauth2: boolean + allowOauth2: boolean, + isCopilot: boolean ): boolean { const pathObj = spec.paths[path]; method = method.toLocaleLowerCase(); @@ -176,19 +217,33 @@ export class Utils { if (requestJsonBody) { const requestBodySchema = requestJsonBody.schema as OpenAPIV3.SchemaObject; - requestBodyParamResult = Utils.checkPostBody(requestBodySchema, requestBody.required); + + if (isCopilot && requestBodySchema.type !== "object") { + return false; + } + + requestBodyParamResult = Utils.checkPostBody( + requestBodySchema, + requestBody.required, + isCopilot + ); } if (!requestBodyParamResult.isValid) { return false; } - const paramResult = Utils.checkParameters(paramObject); + const paramResult = Utils.checkParameters(paramObject, isCopilot); if (!paramResult.isValid) { return false; } + // Copilot support arbitrary parameters + if (isCopilot) { + return true; + } + if (requestBodyParamResult.requiredNum + paramResult.requiredNum > 1) { if ( allowMultipleParameters && @@ -399,7 +454,8 @@ export class Utils { allowMissingId: boolean, allowAPIKeyAuth: boolean, allowMultipleParameters: boolean, - allowOauth2: boolean + allowOauth2: boolean, + isCopilot: boolean ): ErrorResult[] { const errors: ErrorResult[] = []; @@ -435,7 +491,8 @@ export class Utils { allowMissingId, allowAPIKeyAuth, allowMultipleParameters, - allowOauth2 + allowOauth2, + isCopilot ) ) { if (operationObject?.servers && operationObject.servers.length >= 1) { @@ -631,7 +688,8 @@ export class Utils { allowMissingId: boolean, allowAPIKeyAuth: boolean, allowMultipleParameters: boolean, - allowOauth2: boolean + allowOauth2: boolean, + isCopilot: boolean ): { [key: string]: OpenAPIV3.OperationObject; } { @@ -649,7 +707,8 @@ export class Utils { allowMissingId, allowAPIKeyAuth, allowMultipleParameters, - allowOauth2 + allowOauth2, + isCopilot ) ) { const operationObject = (methods as any)[method] as OpenAPIV3.OperationObject; @@ -667,7 +726,8 @@ export class Utils { allowMissingId: boolean, allowAPIKeyAuth: boolean, allowMultipleParameters: boolean, - allowOauth2: boolean + allowOauth2: boolean, + isCopilot: boolean ): ValidateResult { const errors: ErrorResult[] = []; const warnings: WarningResult[] = []; @@ -685,7 +745,8 @@ export class Utils { allowMissingId, allowAPIKeyAuth, allowMultipleParameters, - allowOauth2 + allowOauth2, + isCopilot ); errors.push(...serverErrors); @@ -707,7 +768,8 @@ export class Utils { allowMissingId, allowAPIKeyAuth, allowMultipleParameters, - allowOauth2 + allowOauth2, + isCopilot ); if (Object.keys(apiMap).length === 0) { errors.push({ diff --git a/packages/spec-parser/test/browser/specParser.browser.test.ts b/packages/spec-parser/test/browser/specParser.browser.test.ts index 4be9788038..7ab964ab3b 100644 --- a/packages/spec-parser/test/browser/specParser.browser.test.ts +++ b/packages/spec-parser/test/browser/specParser.browser.test.ts @@ -771,6 +771,25 @@ describe("SpecParser in Browser", () => { }); }); + describe("generateForCopilot", () => { + it("should throw not implemented error", async () => { + try { + const specParser = new SpecParser("path/to/spec.yaml"); + const filter = ["get /hello"]; + const outputSpecPath = "path/to/output.yaml"; + const result = await specParser.generateForCopilot( + "path/to/manifest.json", + filter, + outputSpecPath, + "ai-plugin" + ); + expect.fail("Should throw not implemented error"); + } catch (error: any) { + expect(error.message).to.equal("Method not implemented."); + } + }); + }); + describe("list", () => { it("should throw an error when the SwaggerParser library throws an error", async () => { try { diff --git a/packages/spec-parser/test/manifestUpdater.test.ts b/packages/spec-parser/test/manifestUpdater.test.ts index 3487eeaf96..85527fbd36 100644 --- a/packages/spec-parser/test/manifestUpdater.test.ts +++ b/packages/spec-parser/test/manifestUpdater.test.ts @@ -11,6 +11,630 @@ import { SpecParserError } from "../src/specParserError"; import { ErrorType, WarningType } from "../src/interfaces"; import { ConstantString } from "../src/constants"; import { Utils } from "../src/utils"; +import { PluginManifestSchema } from "@microsoft/teams-manifest"; + +describe("updateManifestWithAiPlugin", () => { + afterEach(() => { + sinon.restore(); + }); + + it("should update the manifest with the correct manifest and apiPlugin files", async () => { + const spec: any = { + openapi: "3.0.2", + info: { + title: "My API", + description: "My API description", + }, + servers: [ + { + url: "/v3", + }, + ], + paths: { + "/pets": { + get: { + operationId: "getPets", + summary: "Get all pets", + description: "Returns all pets from the system that the user has access to", + parameters: [ + { + name: "limit", + description: "Maximum number of pets to return", + required: true, + schema: { + type: "integer", + }, + }, + ], + }, + post: { + operationId: "createPet", + summary: "Create a pet", + description: "Create a new pet in the store", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + required: ["name"], + properties: { + name: { + type: "string", + description: "Name of the pet", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const manifestPath = "/path/to/your/manifest.json"; + const outputSpecPath = "/path/to/your/spec/outputSpec.yaml"; + const pluginFilePath = "/path/to/your/ai-plugin.json"; + + sinon.stub(fs, "pathExists").resolves(true); + const originalManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { short: "Original Short Description", full: "Original Full Description" }, + }; + const expectedManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { short: "My API", full: "My API description" }, + apiPlugins: [ + { + pluginFile: "ai-plugin.json", + }, + ], + }; + + const expectedApiPlugins: PluginManifestSchema = { + schema_version: "v2", + name_for_human: "My API", + description_for_human: "My API description", + functions: [ + { + name: "getPets", + description: "Returns all pets from the system that the user has access to", + parameters: { + type: "object", + properties: { + limit: { + type: "integer", + description: "Maximum number of pets to return", + }, + }, + required: ["limit"], + }, + }, + { + name: "createPet", + description: "Create a new pet in the store", + parameters: { + type: "object", + required: ["name"], + properties: { + name: { + type: "string", + description: "Name of the pet", + }, + }, + }, + }, + ], + runtimes: [ + { + type: "OpenApi", + auth: { + type: "none", + }, + spec: { + url: "spec/outputSpec.yaml", + }, + run_for_functions: ["getPets", "createPet"], + }, + ], + }; + sinon.stub(fs, "readJSON").resolves(originalManifest); + + const [manifest, apiPlugin] = await ManifestUpdater.updateManifestWithAiPlugin( + manifestPath, + outputSpecPath, + pluginFilePath, + spec + ); + + expect(manifest).to.deep.equal(expectedManifest); + expect(apiPlugin).to.deep.equal(expectedApiPlugins); + }); + + it("should update the manifest with the correct manifest and apiPlugin files with optional parameters", async () => { + const spec: any = { + openapi: "3.0.2", + info: { + title: "My API", + description: "My API description", + }, + servers: [ + { + url: "/v3", + }, + ], + paths: { + "/pets": { + get: { + operationId: "getPets", + summary: "Get all pets", + description: "Returns all pets from the system that the user has access to", + parameters: [ + { + name: "limit", + description: "Maximum number of pets to return", + required: true, + schema: { + type: "integer", + }, + }, + { + name: "id", + schema: { + type: "string", + }, + }, + ], + }, + post: { + operationId: "createPet", + summary: "Create a pet", + description: "Create a new pet in the store", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + required: ["name"], + properties: { + name: { + type: "string", + description: "Name of the pet", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const manifestPath = "/path/to/your/manifest.json"; + const outputSpecPath = "/path/to/your/spec/outputSpec.yaml"; + const pluginFilePath = "/path/to/your/ai-plugin.json"; + + sinon.stub(fs, "pathExists").resolves(true); + const originalManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { short: "Original Short Description", full: "Original Full Description" }, + }; + const expectedManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { short: "My API", full: "My API description" }, + apiPlugins: [ + { + pluginFile: "ai-plugin.json", + }, + ], + }; + + const expectedApiPlugins: PluginManifestSchema = { + schema_version: "v2", + name_for_human: "My API", + description_for_human: "My API description", + functions: [ + { + name: "getPets", + description: "Returns all pets from the system that the user has access to", + parameters: { + type: "object", + properties: { + limit: { + type: "integer", + description: "Maximum number of pets to return", + }, + id: { + type: "string", + description: "", + }, + }, + required: ["limit"], + }, + }, + { + name: "createPet", + description: "Create a new pet in the store", + parameters: { + type: "object", + required: ["name"], + properties: { + name: { + type: "string", + description: "Name of the pet", + }, + }, + }, + }, + ], + runtimes: [ + { + type: "OpenApi", + auth: { + type: "none", + }, + spec: { + url: "spec/outputSpec.yaml", + }, + run_for_functions: ["getPets", "createPet"], + }, + ], + }; + sinon.stub(fs, "readJSON").resolves(originalManifest); + + const [manifest, apiPlugin] = await ManifestUpdater.updateManifestWithAiPlugin( + manifestPath, + outputSpecPath, + pluginFilePath, + spec + ); + + expect(manifest).to.deep.equal(expectedManifest); + expect(apiPlugin).to.deep.equal(expectedApiPlugins); + }); + + it("should generate default ai plugin file if no api", async () => { + const spec: any = { + openapi: "3.0.2", + info: { + title: "My API", + description: "My API description", + }, + servers: [ + { + url: "/v3", + }, + ], + paths: {}, + }; + const manifestPath = "/path/to/your/manifest.json"; + const outputSpecPath = "/path/to/your/spec/outputSpec.yaml"; + const pluginFilePath = "/path/to/your/ai-plugin.json"; + + sinon.stub(fs, "pathExists").resolves(true); + const originalManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { short: "Original Short Description", full: "Original Full Description" }, + }; + const expectedManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { short: "My API", full: "My API description" }, + apiPlugins: [ + { + pluginFile: "ai-plugin.json", + }, + ], + }; + + const expectedApiPlugins: PluginManifestSchema = { + schema_version: "v2", + name_for_human: "My API", + description_for_human: "My API description", + functions: [], + runtimes: [ + { + type: "OpenApi", + auth: { + type: "none", + }, + spec: { + url: "spec/outputSpec.yaml", + }, + run_for_functions: [], + }, + ], + }; + sinon.stub(fs, "readJSON").resolves(originalManifest); + + const [manifest, apiPlugin] = await ManifestUpdater.updateManifestWithAiPlugin( + manifestPath, + outputSpecPath, + pluginFilePath, + spec + ); + + expect(manifest).to.deep.equal(expectedManifest); + expect(apiPlugin).to.deep.equal(expectedApiPlugins); + }); + + it("should truncate if title is long", async () => { + const spec: any = { + openapi: "3.0.2", + info: { + title: + "long title long title long title long title long title long title long title long title long title long title long title long title", + description: "This is the description", + }, + servers: [ + { + url: "/v3", + }, + ], + paths: { + "/pets": { + get: { + operationId: "getPets", + summary: "Get all pets", + description: "Returns all pets from the system that the user has access to", + parameters: [ + { + name: "limit", + description: "Maximum number of pets to return", + required: true, + schema: { + type: "integer", + }, + }, + ], + }, + post: { + operationId: "createPet", + summary: "Create a pet", + description: "Create a new pet in the store", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + required: ["name"], + properties: { + name: { + type: "string", + description: "Name of the pet", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const manifestPath = "/path/to/your/manifest.json"; + const outputSpecPath = "/path/to/your/spec/outputSpec.yaml"; + const pluginFilePath = "/path/to/your/ai-plugin.json"; + + sinon.stub(fs, "pathExists").resolves(true); + const originalManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { short: "Original Short Description", full: "Original Full Description" }, + }; + const expectedManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { + short: "long title long title long title long title long title long title long title lon", + full: "This is the description", + }, + apiPlugins: [ + { + pluginFile: "ai-plugin.json", + }, + ], + }; + + const expectedApiPlugins: PluginManifestSchema = { + schema_version: "v2", + name_for_human: + "long title long title long title long title long title long title long title long title long title long title long title long title", + description_for_human: "This is the description", + functions: [ + { + name: "getPets", + description: "Returns all pets from the system that the user has access to", + parameters: { + type: "object", + properties: { + limit: { + type: "integer", + description: "Maximum number of pets to return", + }, + }, + required: ["limit"], + }, + }, + { + name: "createPet", + description: "Create a new pet in the store", + parameters: { + type: "object", + required: ["name"], + properties: { + name: { + type: "string", + description: "Name of the pet", + }, + }, + }, + }, + ], + runtimes: [ + { + type: "OpenApi", + auth: { + type: "none", + }, + spec: { + url: "spec/outputSpec.yaml", + }, + run_for_functions: ["getPets", "createPet"], + }, + ], + }; + sinon.stub(fs, "readJSON").resolves(originalManifest); + + const [manifest, apiPlugin] = await ManifestUpdater.updateManifestWithAiPlugin( + manifestPath, + outputSpecPath, + pluginFilePath, + spec + ); + + expect(manifest).to.deep.equal(expectedManifest); + expect(apiPlugin).to.deep.equal(expectedApiPlugins); + }); + + it("should throw error if has nested object property", async () => { + const spec: any = { + openapi: "3.0.2", + info: { + title: "My API", + description: "My API description", + }, + servers: [ + { + url: "/v3", + }, + ], + paths: { + "/pets": { + get: { + operationId: "getPets", + summary: "Get all pets", + description: "Returns all pets from the system that the user has access to", + parameters: [ + { + name: "petObj", + description: "Pet object", + required: true, + schema: { + type: "object", + properties: { + id: { + type: "integer", + }, + name: { + type: "string", + }, + }, + }, + }, + ], + }, + }, + }, + }; + const manifestPath = "/path/to/your/manifest.json"; + const outputSpecPath = "/path/to/your/spec/outputSpec.yaml"; + sinon.stub(fs, "pathExists").resolves(true); + const originalManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { short: "My API", full: "My API description" }, + }; + + sinon.stub(fs, "readJSON").resolves(originalManifest); + + const pluginFilePath = "/path/to/your/ai-plugin.json"; + + try { + await ManifestUpdater.updateManifestWithAiPlugin( + manifestPath, + outputSpecPath, + pluginFilePath, + spec + ); + expect.fail("Expected updateManifest to throw a SpecParserError"); + } catch (err: any) { + expect(err).to.be.instanceOf(SpecParserError); + expect(err.errorType).to.equal(ErrorType.UpdateManifestFailed); + expect(err.message).to.equal( + "Unsupported schema in get /pets: " + + JSON.stringify({ + type: "object", + properties: { + id: { + type: "integer", + }, + name: { + type: "string", + }, + }, + }) + ); + } + }); + + it("should throw error if request body is not object", async () => { + const spec: any = { + openapi: "3.0.2", + info: { + title: "My API", + description: "My API description", + }, + servers: [ + { + url: "/v3", + }, + ], + paths: { + "/pets": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "string", + }, + }, + }, + }, + operationId: "postPets", + summary: "Get all pets", + description: "Returns all pets from the system that the user has access to", + }, + }, + }, + }; + const manifestPath = "/path/to/your/manifest.json"; + const outputSpecPath = "/path/to/your/spec/outputSpec.yaml"; + sinon.stub(fs, "pathExists").resolves(true); + const originalManifest = { + name: { short: "Original Name", full: "Original Full Name" }, + description: { short: "My API", full: "My API description" }, + }; + + sinon.stub(fs, "readJSON").resolves(originalManifest); + const pluginFilePath = "/path/to/your/ai-plugin.json"; + + try { + await ManifestUpdater.updateManifestWithAiPlugin( + manifestPath, + outputSpecPath, + pluginFilePath, + spec + ); + expect.fail("Expected updateManifest to throw a SpecParserError"); + } catch (err: any) { + expect(err).to.be.instanceOf(SpecParserError); + expect(err.errorType).to.equal(ErrorType.UpdateManifestFailed); + expect(err.message).to.equal( + "Unsupported schema in post /pets: " + + JSON.stringify({ + type: "string", + }) + ); + } + }); +}); describe("manifestUpdater", () => { const spec: any = { diff --git a/packages/spec-parser/test/specFilter.test.ts b/packages/spec-parser/test/specFilter.test.ts index 86c4f9f876..463828fbb9 100644 --- a/packages/spec-parser/test/specFilter.test.ts +++ b/packages/spec-parser/test/specFilter.test.ts @@ -155,6 +155,7 @@ describe("specFilter", () => { true, false, false, + false, false ); expect(actualSpec).to.deep.equal(expectedSpec); @@ -204,6 +205,7 @@ describe("specFilter", () => { true, false, false, + false, false ); expect(actualSpec).to.deep.equal(expectedSpec); @@ -256,6 +258,7 @@ describe("specFilter", () => { false, false, false, + false, false ); @@ -339,6 +342,7 @@ describe("specFilter", () => { true, false, false, + false, false ); @@ -348,7 +352,7 @@ describe("specFilter", () => { it("should not filter anything if filter item not exist", () => { const filter = ["get /hello"]; const clonedSpec = { ...unResolveSpec }; - SpecFilter.specFilter(filter, unResolveSpec, unResolveSpec, true, false, false, false); + SpecFilter.specFilter(filter, unResolveSpec, unResolveSpec, true, false, false, false, false); expect(clonedSpec).to.deep.equal(unResolveSpec); }); @@ -381,6 +385,7 @@ describe("specFilter", () => { true, false, false, + false, false ); @@ -390,7 +395,7 @@ describe("specFilter", () => { it("should not modify the original OpenAPI spec", () => { const filter = ["get /hello"]; const clonedSpec = { ...unResolveSpec }; - SpecFilter.specFilter(filter, unResolveSpec, unResolveSpec, true, false, false, false); + SpecFilter.specFilter(filter, unResolveSpec, unResolveSpec, true, false, false, false, false); expect(clonedSpec).to.deep.equal(unResolveSpec); }); @@ -402,7 +407,7 @@ describe("specFilter", () => { .throws(new Error("isSupportedApi error")); try { - SpecFilter.specFilter(filter, unResolveSpec, unResolveSpec, true, false, false, false); + SpecFilter.specFilter(filter, unResolveSpec, unResolveSpec, true, false, false, false, false); expect.fail("Expected specFilter to throw a SpecParserError"); } catch (err: any) { expect(err).to.be.instanceOf(SpecParserError); diff --git a/packages/spec-parser/test/specParser.test.ts b/packages/spec-parser/test/specParser.test.ts index 1fb44afa84..f0364de561 100644 --- a/packages/spec-parser/test/specParser.test.ts +++ b/packages/spec-parser/test/specParser.test.ts @@ -549,6 +549,216 @@ describe("SpecParser", () => { }); }); + describe("generateForCopilot", () => { + it("should throw an error if the signal is aborted", async () => { + const manifestPath = "path/to/manifest"; + const filter = ["GET /pet/{petId}"]; + const specPath = "path/to/spec"; + const signal = { aborted: true } as AbortSignal; + const specParser = new SpecParser("/path/to/spec.yaml"); + const pluginFilePath = "ai-plugin.json"; + + try { + await specParser.generateForCopilot(manifestPath, filter, specPath, pluginFilePath, signal); + expect.fail("Expected an error to be thrown"); + } catch (err) { + expect((err as SpecParserError).message).contain(ConstantString.CancelledMessage); + expect((err as SpecParserError).errorType).to.equal(ErrorType.Cancelled); + } + }); + + it("should throw an error if the signal is aborted after loadSpec", async () => { + const manifestPath = "path/to/manifest"; + const filter = ["GET /pet/{petId}"]; + const specPath = "path/to/spec"; + const adaptiveCardFolder = "path/to/adaptiveCardFolder"; + const pluginFilePath = "ai-plugin.json"; + + try { + const signal = { aborted: false } as any; + + const specParser = new SpecParser("path/to/spec.yaml"); + const spec = { openapi: "3.0.0", paths: {} }; + + const parseStub = sinon.stub(specParser as any, "loadSpec").callsFake(async () => { + signal.aborted = true; + return Promise.resolve(); + }); + const dereferenceStub = sinon.stub(specParser.parser, "dereference").resolves(spec as any); + await specParser.generateForCopilot(manifestPath, filter, specPath, pluginFilePath, signal); + expect.fail("Expected an error to be thrown"); + } catch (err) { + expect((err as SpecParserError).message).contain(ConstantString.CancelledMessage); + expect((err as SpecParserError).errorType).to.equal(ErrorType.Cancelled); + } + }); + + it("should throw an error if the signal is aborted after specFilter", async () => { + try { + const signal = { aborted: false } as any; + + const specParser = new SpecParser("path/to/spec.yaml"); + const spec = { openapi: "3.0.0", paths: {} }; + const parseStub = sinon.stub(specParser.parser, "parse").resolves(spec as any); + const dereferenceStub = sinon.stub(specParser.parser, "dereference").resolves(spec as any); + const specFilterStub = sinon + .stub(SpecFilter, "specFilter") + .callsFake((filter: string[], unResolveSpec: any) => { + signal.aborted = true; + return {} as any; + }); + const outputFileStub = sinon.stub(fs, "outputFile").resolves(); + const outputJSONStub = sinon.stub(fs, "outputJSON").resolves(); + const JsyamlSpy = sinon.spy(jsyaml, "dump"); + const pluginFilePath = "ai-plugin.json"; + + const filter = ["get /hello"]; + + const outputSpecPath = "path/to/output.yaml"; + + await specParser.generateForCopilot( + "path/to/manifest.json", + filter, + outputSpecPath, + pluginFilePath, + signal + ); + + expect.fail("Expected an error to be thrown"); + } catch (err) { + expect((err as SpecParserError).message).contain(ConstantString.CancelledMessage); + expect((err as SpecParserError).errorType).to.equal(ErrorType.Cancelled); + } + }); + + it("should throw an error if the signal is aborted after specFilter", async () => { + try { + const signal = { aborted: false } as any; + + const specParser = new SpecParser("path/to/spec.yaml"); + const spec = { openapi: "3.0.0", paths: {} }; + const parseStub = sinon.stub(specParser.parser, "parse").resolves(spec as any); + const dereferenceStub = sinon.stub(specParser.parser, "dereference").resolves(spec as any); + const specFilterStub = sinon.stub(SpecFilter, "specFilter").resolves(); + const outputFileStub = sinon.stub(fs, "outputFile").resolves(); + const outputJSONStub = sinon.stub(fs, "outputJSON").resolves(); + + const JsyamlSpy = sinon.stub(jsyaml, "dump").callsFake((obj) => { + signal.aborted = true; + return {} as any; + }); + + const filter = ["get /hello"]; + + const outputSpecPath = "path/to/output.yaml"; + const pluginFilePath = "ai-plugin.json"; + + await specParser.generateForCopilot( + "path/to/manifest.json", + filter, + outputSpecPath, + pluginFilePath, + signal + ); + + expect.fail("Expected an error to be thrown"); + } catch (err) { + expect((err as SpecParserError).message).contain(ConstantString.CancelledMessage); + expect((err as SpecParserError).errorType).to.equal(ErrorType.Cancelled); + } + }); + + it("should generate a new spec and write it to a yaml file if spec contains api", async () => { + const specParser = new SpecParser("path/to/spec.yaml"); + const spec = { + openapi: "3.0.0", + paths: { + "/hello": { + get: { + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const parseStub = sinon.stub(specParser.parser, "parse").resolves(spec as any); + const dereferenceStub = sinon.stub(specParser.parser, "dereference").resolves(spec as any); + const specFilterStub = sinon.stub(SpecFilter, "specFilter").returns({} as any); + const outputFileStub = sinon.stub(fs, "outputFile").resolves(); + const outputJSONStub = sinon.stub(fs, "outputJSON").resolves(); + const JsyamlSpy = sinon.spy(jsyaml, "dump"); + + const updateManifestWithAiPluginStub = sinon + .stub(ManifestUpdater, "updateManifestWithAiPlugin") + .resolves([{}, {}] as any); + + const filter = ["get /hello"]; + + const outputSpecPath = "path/to/output.yaml"; + const pluginFilePath = "ai-plugin.json"; + const result = await specParser.generateForCopilot( + "path/to/manifest.json", + filter, + outputSpecPath, + pluginFilePath + ); + + expect(result.allSuccess).to.be.true; + expect(JsyamlSpy.calledOnce).to.be.true; + expect(specFilterStub.calledOnce).to.be.true; + expect(outputFileStub.calledOnce).to.be.true; + expect(updateManifestWithAiPluginStub.calledOnce).to.be.true; + expect(outputFileStub.firstCall.args[0]).to.equal(outputSpecPath); + expect(outputJSONStub.calledTwice).to.be.true; + }); + + it("should throw a SpecParserError if outputFile throws an error", async () => { + const specParser = new SpecParser("path/to/spec.yaml"); + const spec = { openapi: "3.0.0", paths: {} }; + const parseStub = sinon.stub(specParser.parser, "parse").resolves(spec as any); + const dereferenceStub = sinon.stub(specParser.parser, "dereference").resolves(spec as any); + const specFilterStub = sinon.stub(SpecFilter, "specFilter").resolves(); + const outputFileStub = sinon.stub(fs, "outputFile").throws(new Error("outputFile error")); + const outputJSONStub = sinon.stub(fs, "outputJSON").resolves(); + const JSONStringifySpy = sinon.spy(JSON, "stringify"); + const JsyamlSpy = sinon.spy(jsyaml, "dump"); + const manifestUpdaterStub = sinon.stub(ManifestUpdater, "updateManifest").resolves([] as any); + + const filter = ["get /hello"]; + + const outputSpecPath = "path/to/output.json"; + const pluginFilePath = "ai-plugin.json"; + + try { + await specParser.generateForCopilot( + "path/to/manifest.json", + filter, + outputSpecPath, + pluginFilePath + ); + expect.fail("Expected generate to throw a SpecParserError"); + } catch (err: any) { + expect(err).to.be.instanceOf(SpecParserError); + expect(err.errorType).to.equal(ErrorType.GenerateFailed); + expect(err.message).to.equal("Error: outputFile error"); + } + }); + }); + describe("generate", () => { it("should throw an error if the signal is aborted", async () => { const manifestPath = "path/to/manifest"; diff --git a/packages/spec-parser/test/utils.test.ts b/packages/spec-parser/test/utils.test.ts index 2adb6964da..35de6854a8 100644 --- a/packages/spec-parser/test/utils.test.ts +++ b/packages/spec-parser/test/utils.test.ts @@ -100,7 +100,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, true); }); @@ -138,7 +147,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, false, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + false, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -190,7 +208,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, true); }); @@ -262,7 +289,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -334,7 +370,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, true, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + true, + false, + false, + false + ); assert.strictEqual(result, true); }); @@ -407,7 +452,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, true, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + true, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -481,7 +535,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, true); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + true, + false + ); assert.strictEqual(result, true); }); @@ -560,7 +623,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, true, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + true, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -639,7 +711,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, true, false, true); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + true, + false, + true, + false + ); assert.strictEqual(result, true); }); @@ -692,7 +773,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, true); }); @@ -745,10 +835,81 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); + it("should return true if method is POST, path is valid, parameter is supported and both postBody and parameters contains multiple required param for copilot", () => { + const method = "POST"; + const path = "/users"; + const spec = { + paths: { + "/users": { + post: { + parameters: [ + { + in: "query", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + required: ["name"], + properties: { + name: { + type: "string", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + true, + false, + true + ); + assert.strictEqual(result, true); + }); + it("should support multiple required parameters", () => { const method = "POST"; const path = "/users"; @@ -798,7 +959,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, true, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + true, + false, + false + ); assert.strictEqual(result, true); }); @@ -859,10 +1029,89 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, true, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + true, + false, + false + ); assert.strictEqual(result, false); }); + it("should not support multiple required parameters count larger than 5 for copilot", () => { + const method = "POST"; + const path = "/users"; + const spec = { + paths: { + "/users": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + required: ["id1", "id2", "id3", "id4", "id5", "id6"], + properties: { + id1: { + type: "string", + }, + id2: { + type: "string", + }, + id3: { + type: "string", + }, + id4: { + type: "string", + }, + id5: { + type: "string", + }, + id6: { + type: "string", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + true, + false, + true + ); + assert.strictEqual(result, true); + }); + it("should return false if method is POST, but requestBody contains unsupported parameter and required", () => { const method = "POST"; const path = "/users"; @@ -915,7 +1164,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -972,10 +1230,155 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, true); }); + it("should return false if method is POST, but parameters contain nested object", () => { + const method = "POST"; + const path = "/users"; + const spec = { + paths: { + "/users": { + post: { + parameters: [ + { + in: "query", + required: true, + schema: { + type: "object", + required: ["name"], + properties: { + name: { + type: "object", + properties: { + id: { + type: "string", + }, + }, + }, + }, + }, + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + type: "string", + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + true + ); + assert.strictEqual(result, false); + }); + + it("should return false if method is POST, but requestBody contain nested object", () => { + const method = "POST"; + const path = "/users"; + const spec = { + paths: { + "/users": { + post: { + parameters: [ + { + in: "query", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + required: ["name"], + properties: { + name: { + type: "object", + properties: { + id: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + true + ); + assert.strictEqual(result, false); + }); + it("should return true if method is POST, path is valid, parameter is supported and only one required param in postBody", () => { const method = "POST"; const path = "/users"; @@ -1010,7 +1413,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, true); }); @@ -1048,7 +1460,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -1085,7 +1506,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -1122,7 +1552,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -1160,7 +1599,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -1198,11 +1646,20 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); - it("should return false if parameter is in header and required supported", () => { + it("should return false if parameter is in header and required", () => { const method = "GET"; const path = "/users"; const spec = { @@ -1236,10 +1693,70 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); + it("should return true if parameter is in header and required for copilot", () => { + const method = "GET"; + const path = "/users"; + const spec = { + paths: { + "/users": { + get: { + parameters: [ + { + in: "header", + required: true, + schema: { type: "string" }, + }, + { + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + true + ); + assert.strictEqual(result, true); + }); + it("should return false if there is no parameters", () => { const method = "GET"; const path = "/users"; @@ -1263,10 +1780,55 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); + it("should return true if there is no parameters for copilot", () => { + const method = "GET"; + const path = "/users"; + const spec = { + paths: { + "/users": { + get: { + parameters: [], + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }; + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + true + ); + assert.strictEqual(result, true); + }); + it("should return false if parameters is null", () => { const method = "GET"; const path = "/users"; @@ -1289,7 +1851,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -1321,7 +1892,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); @@ -1385,7 +1965,65 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); + assert.strictEqual(result, false); + }); + + it("should return false if method is POST, and request body schema is not object", () => { + const method = "POST"; + const path = "/users"; + const spec = { + paths: { + "/users": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: "string", + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + true + ); assert.strictEqual(result, false); }); @@ -1433,7 +2071,16 @@ describe("utils", () => { }, }, }; - const result = Utils.isSupportedApi(method, path, spec as any, true, false, false, false); + const result = Utils.isSupportedApi( + method, + path, + spec as any, + true, + false, + false, + false, + false + ); assert.strictEqual(result, false); }); }); @@ -1470,7 +2117,7 @@ describe("utils", () => { { in: "query", required: true, schema: { type: "string" } }, { in: "path", required: false, schema: { type: "string" } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, true); }); @@ -1479,7 +2126,7 @@ describe("utils", () => { { in: "query", required: true, schema: { type: "string" } }, { in: "path", required: true, schema: { type: "string" } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, true); assert.strictEqual(result.requiredNum, 2); assert.strictEqual(result.optionalNum, 0); @@ -1491,7 +2138,7 @@ describe("utils", () => { { in: "path", required: false, schema: { type: "string" } }, { in: "header", required: true, schema: { type: "string" } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, false); }); @@ -1501,7 +2148,7 @@ describe("utils", () => { { in: "path", required: false, schema: { type: "string" } }, { in: "header", required: true, schema: { type: "string", default: "value" } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, true); // header param is ignored assert.strictEqual(result.requiredNum, 1); @@ -1514,7 +2161,7 @@ describe("utils", () => { { in: "path", required: false, schema: { type: "string" } }, { in: "query", required: true, schema: { type: "string" } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, true); assert.strictEqual(result.requiredNum, 1); assert.strictEqual(result.optionalNum, 2); @@ -1526,7 +2173,7 @@ describe("utils", () => { { in: "path", required: false, schema: { type: "string" } }, { in: "query", required: true, schema: { type: "array", default: ["item"] } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, true); assert.strictEqual(result.requiredNum, 1); assert.strictEqual(result.optionalNum, 1); @@ -1538,7 +2185,7 @@ describe("utils", () => { { in: "path", required: false, schema: { type: "string" } }, { in: "header", required: false, schema: { type: "string" } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, true); assert.strictEqual(result.requiredNum, 1); assert.strictEqual(result.optionalNum, 1); @@ -1549,7 +2196,7 @@ describe("utils", () => { { in: "query", required: true, schema: { type: "string" } }, { in: "path", required: true, schema: { type: "array" } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, false); }); @@ -1558,7 +2205,7 @@ describe("utils", () => { { in: "query", required: false, schema: { type: "string" } }, { in: "path", required: true, schema: { type: "object" } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, false); }); @@ -1567,7 +2214,7 @@ describe("utils", () => { { in: "query", required: false, schema: { type: "string" } }, { in: "path", required: false, schema: { type: "object" } }, ]; - const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[]); + const result = Utils.checkParameters(paramObject as OpenAPIV3.ParameterObject[], false); assert.strictEqual(result.isValid, true); assert.strictEqual(result.requiredNum, 0); assert.strictEqual(result.optionalNum, 1); @@ -1731,7 +2378,14 @@ describe("utils", () => { describe("validateServer", () => { it("should return an error if there is no server information", () => { const spec = { paths: {} }; - const errors = Utils.validateServer(spec as OpenAPIV3.Document, true, false, false, false); + const errors = Utils.validateServer( + spec as OpenAPIV3.Document, + true, + false, + false, + false, + false + ); assert.deepStrictEqual(errors, [ { type: ErrorType.NoServerInformation, @@ -1750,7 +2404,7 @@ describe("utils", () => { }, }, }; - const errors = Utils.validateServer(spec as any, true, false, false, false); + const errors = Utils.validateServer(spec as any, true, false, false, false, false); assert.deepStrictEqual(errors, [ { type: ErrorType.NoServerInformation, @@ -1764,7 +2418,14 @@ describe("utils", () => { servers: [{ url: "https://example.com" }], paths: {}, }; - const errors = Utils.validateServer(spec as OpenAPIV3.Document, true, false, false, false); + const errors = Utils.validateServer( + spec as OpenAPIV3.Document, + true, + false, + false, + false, + false + ); assert.deepStrictEqual(errors, []); }); @@ -1776,7 +2437,7 @@ describe("utils", () => { }, }, }; - const errors = Utils.validateServer(spec as any, true, false, false, false); + const errors = Utils.validateServer(spec as any, true, false, false, false, false); assert.deepStrictEqual(errors, []); }); @@ -1808,7 +2469,7 @@ describe("utils", () => { }, }, }; - const errors = Utils.validateServer(spec as any, true, false, false, false); + const errors = Utils.validateServer(spec as any, true, false, false, false, false); assert.deepStrictEqual(errors, []); }); @@ -1842,7 +2503,7 @@ describe("utils", () => { }, }, }; - const errors = Utils.validateServer(spec as any, true, false, false, false); + const errors = Utils.validateServer(spec as any, true, false, false, false, false); assert.deepStrictEqual(errors, []); }); @@ -1876,7 +2537,7 @@ describe("utils", () => { }, }, }; - const errors = Utils.validateServer(spec as any, true, false, false, false); + const errors = Utils.validateServer(spec as any, true, false, false, false, false); assert.deepStrictEqual(errors, [ { type: ErrorType.RelativeServerUrlNotSupported, @@ -1897,6 +2558,35 @@ describe("utils", () => { }); }); + describe("hasNestedObjectInSchema", () => { + it("should return false if schema type is not object", () => { + const schema: OpenAPIV3.SchemaObject = { + type: "string", + }; + expect(Utils.hasNestedObjectInSchema(schema)).to.be.false; + }); + + it("should return false if schema type is object but no nested object property", () => { + const schema: OpenAPIV3.SchemaObject = { + type: "object", + properties: { + name: { type: "string" }, + }, + }; + expect(Utils.hasNestedObjectInSchema(schema)).to.be.false; + }); + + it("should return true if schema type is object and has nested object property", () => { + const schema: OpenAPIV3.SchemaObject = { + type: "object", + properties: { + nestedObject: { type: "object" }, + }, + }; + expect(Utils.hasNestedObjectInSchema(schema)).to.be.true; + }); + }); + describe("getResponseJson", () => { it("should return an empty object if no JSON response is defined", () => { const operationObject = {};