diff --git a/packages/fx-core/src/component/generator/copilotPlugin/helper.ts b/packages/fx-core/src/component/generator/copilotPlugin/helper.ts index 426e4ff62a..44798c0eb1 100644 --- a/packages/fx-core/src/component/generator/copilotPlugin/helper.ts +++ b/packages/fx-core/src/component/generator/copilotPlugin/helper.ts @@ -39,6 +39,7 @@ import { ProjectType, ParseOptions, AdaptiveCardGenerator, + Utils, } from "@microsoft/m365-spec-parser"; import fs from "fs-extra"; import { getLocalizedString } from "../../../common/localizeUtils"; @@ -52,7 +53,7 @@ import { QuestionNames } from "../../../question/questionNames"; import { pluginManifestUtils } from "../../driver/teamsApp/utils/PluginManifestUtils"; import { copilotPluginApiSpecOptionId } from "../../../question/constants"; import { OpenAPIV3 } from "openapi-types"; -import { ProgrammingLanguage } from "../../../question"; +import { CustomCopilotRagOptions, ProgrammingLanguage } from "../../../question"; const manifestFilePath = "/.well-known/ai-plugin.json"; const componentName = "OpenAIPluginManifestHelper"; @@ -174,6 +175,8 @@ export async function listOperations( } const isPlugin = inputs[QuestionNames.Capabilities] === copilotPluginApiSpecOptionId; + const isCustomApi = + inputs[QuestionNames.CustomCopilotRag] === CustomCopilotRagOptions.customApi().id; try { const allowAPIKeyAuth = isPlugin || isApiKeyEnabled(); @@ -182,6 +185,10 @@ export async function listOperations( apiSpecUrl as string, isPlugin ? copilotPluginParserOptions + : isCustomApi + ? { + projectType: ProjectType.TeamsAi, + } : { allowAPIKeyAuth, allowMultipleParameters, @@ -716,10 +723,12 @@ interface SpecObject { pathUrl: string; method: string; item: OpenAPIV3.OperationObject; + auth: boolean; } -function parseSpec(spec: OpenAPIV3.Document): SpecObject[] { +function parseSpec(spec: OpenAPIV3.Document): [SpecObject[], boolean] { const res: SpecObject[] = []; + let needAuth = false; const paths = spec.paths; if (paths) { @@ -731,10 +740,16 @@ function parseSpec(spec: OpenAPIV3.Document): SpecObject[] { if (method === "get" || method === "post") { const operationItem = (operations as any)[method] as OpenAPIV3.OperationObject; if (operationItem) { + const authResult = Utils.getAuthArray(operationItem.security, spec); + const hasAuth = authResult.length != 0; + if (hasAuth) { + needAuth = true; + } res.push({ item: operationItem, method: method, pathUrl: pathUrl, + auth: hasAuth, }); } } @@ -743,7 +758,7 @@ function parseSpec(spec: OpenAPIV3.Document): SpecObject[] { } } - return res; + return [res, needAuth]; } async function updatePromptForCustomApi( @@ -835,6 +850,7 @@ const ActionCode = { javascript: ` app.ai.action("{{operationId}}", async (context, state, parameter) => { const client = await api.getClient(); + // Add authentication configuration for the client const path = client.paths["{{pathUrl}}"]; if (path && path.{{method}}) { const result = await path.{{method}}(parameter.path, parameter.body, { @@ -851,6 +867,7 @@ app.ai.action("{{operationId}}", async (context, state, parameter) => { typescript: ` app.ai.action("{{operationId}}", async (context: TurnContext, state: ApplicationTurnState, parameter: any) => { const client = await api.getClient(); + // Add authentication configuration for the client const path = client.paths["{{pathUrl}}"]; if (path && path.{{method}}) { const result = await path.{{method}}(parameter.path, parameter.body, { @@ -866,11 +883,23 @@ app.ai.action("{{operationId}}", async (context: TurnContext, state: Application `, }; +const AuthCode = { + javascript: { + actionCode: `addAuthConfig(client);`, + actionPlaceholder: `// Add authentication configuration for the client`, + }, + typescript: { + actionCode: `addAuthConfig(client);`, + actionPlaceholder: `// Add authentication configuration for the client`, + }, +}; + async function updateCodeForCustomApi( specItems: SpecObject[], language: string, destinationPath: string, - openapiSpecFileName: string + openapiSpecFileName: string, + needAuth: boolean ): Promise { if (language === ProgrammingLanguage.JS || language === ProgrammingLanguage.TS) { const codeTemplate = @@ -878,10 +907,14 @@ async function updateCodeForCustomApi( const appFolderPath = path.join(destinationPath, "src", "app"); const actionsCode = []; + const authCodeTemplate = + AuthCode[language === ProgrammingLanguage.JS ? "javascript" : "typescript"]; for (const item of specItems) { + const auth = item.auth; const code = codeTemplate + .replace(authCodeTemplate.actionPlaceholder, auth ? authCodeTemplate.actionCode : "") .replace(/{{operationId}}/g, item.item.operationId!) - .replace("{{pathUrl}}", item.pathUrl) + .replace(/{{pathUrl}}/g, item.pathUrl) .replace(/{{method}}/g, item.method); actionsCode.push(code); } @@ -911,7 +944,7 @@ export async function updateForCustomApi( // 1. update prompt folder await updatePromptForCustomApi(spec, language, chatFolder); - const specItems = parseSpec(spec); + const [specItems, needAuth] = parseSpec(spec); // 2. update adaptive card folder await updateAdaptiveCardForCustomApi(specItems, language, destinationPath); @@ -920,5 +953,5 @@ export async function updateForCustomApi( await updateActionForCustomApi(specItems, language, chatFolder); // 4. update code - await updateCodeForCustomApi(specItems, language, destinationPath, openapiSpecFileName); + await updateCodeForCustomApi(specItems, language, destinationPath, openapiSpecFileName, needAuth); } diff --git a/packages/fx-core/src/question/create.ts b/packages/fx-core/src/question/create.ts index 6867017fe8..9dd92a9c92 100644 --- a/packages/fx-core/src/question/create.ts +++ b/packages/fx-core/src/question/create.ts @@ -1997,7 +1997,11 @@ export function apiOperationQuestion(includeExistingAPIs = true): MultiSelectQue staticOptions: [], validation: { validFunc: (input: string[], inputs?: Inputs): string | undefined => { - if (input.length < 1 || input.length > 10) { + if ( + input.length < 1 || + (input.length > 10 && + inputs?.[QuestionNames.CustomCopilotRag] != CustomCopilotRagOptions.customApi().id) + ) { return getLocalizedString( "core.createProjectQuestion.apiSpec.operation.invalidMessage", input.length, diff --git a/packages/fx-core/tests/component/generator/copilotPluginGenerator.test.ts b/packages/fx-core/tests/component/generator/copilotPluginGenerator.test.ts index 8b2a0be8b2..5e968eca6d 100644 --- a/packages/fx-core/tests/component/generator/copilotPluginGenerator.test.ts +++ b/packages/fx-core/tests/component/generator/copilotPluginGenerator.test.ts @@ -30,6 +30,7 @@ import { WarningType, SpecParserError, AdaptiveCardGenerator, + ProjectType, } from "@microsoft/m365-spec-parser"; import { CopilotPluginGenerator } from "../../../src/component/generator/copilotPlugin/generator"; import { assert, expect } from "chai"; @@ -1405,4 +1406,200 @@ describe("updateForCustomApi", async () => { .resolves(Buffer.from("test code // Replace with action code {{OPENAPI_SPEC_PATH}}")); await CopilotPluginHelper.updateForCustomApi(newSpec, "typescript", "path", "openapi.yaml"); }); + + it("happy path with spec with auth", async () => { + const authSpec = { + openapi: "3.0.0", + info: { + title: "My API", + version: "1.0.0", + }, + description: "test", + paths: { + "/hello": { + get: { + operationId: "getHello", + summary: "Returns a greeting", + parameters: [ + { + name: "query", + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "A greeting message", + content: { + "application/json": { + schema: { + type: "string", + }, + }, + }, + }, + }, + security: [ + { + api_key: [], + }, + ], + }, + 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", + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + securitySchemes: { + api_key: { + type: "apiKey", + name: "api_key", + in: "header", + }, + }, + }, + } as OpenAPIV3.Document; + sandbox.stub(fs, "ensureDir").resolves(); + sandbox.stub(fs, "writeFile").callsFake((file, data) => { + if (file === path.join("path", "src", "prompts", "chat", "skprompt.txt")) { + expect(data).to.contains("The following is a conversation with an AI assistant."); + } else if (file === path.join("path", "src", "adaptiveCard", "hello.json")) { + expect(data).to.contains("getHello"); + } else if (file === path.join("path", "src", "prompts", "chat", "actions.json")) { + expect(data).to.contains("getHello"); + } else if (file === path.join("path", "src", "app", "app.ts")) { + expect(data).to.contains(`app.ai.action("getHello"`); + expect(data).not.to.contains("{{"); + expect(data).not.to.contains("// Replace with action code"); + } + }); + sandbox + .stub(fs, "readFile") + .resolves(Buffer.from("test code // Replace with action code {{OPENAPI_SPEC_PATH}}")); + await CopilotPluginHelper.updateForCustomApi(authSpec, "typescript", "path", "openapi.yaml"); + }); +}); + +describe("listOperations", async () => { + const context = createContextV3(); + const sandbox = sinon.createSandbox(); + const inputs = { + "custom-copilot-rag": "custom-copilot-rag-customApi", + platform: Platform.VSCode, + }; + const spec = { + openapi: "3.0.0", + info: { + title: "My API", + version: "1.0.0", + }, + description: "test", + paths: { + "/hello": { + get: { + operationId: "getHello", + summary: "Returns a greeting", + parameters: [ + { + name: "query", + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "A greeting message", + content: { + "application/json": { + schema: { + type: "string", + }, + }, + }, + }, + }, + security: [ + { + api_key: [], + }, + ], + }, + 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", + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + securitySchemes: { + api_key: { + type: "apiKey", + name: "api_key", + in: "header", + }, + }, + }, + } as OpenAPIV3.Document; + + afterEach(async () => { + sandbox.restore(); + }); + + it("allow auth for teams ai project", async () => { + sandbox.stub(CopilotPluginHelper, "formatValidationErrors").resolves([]); + sandbox.stub(CopilotPluginHelper, "logValidationResults").resolves(); + sandbox.stub(SpecParser.prototype, "validate").resolves({ + status: ValidationStatus.Valid, + warnings: [], + errors: [], + }); + sandbox.stub(SpecParser.prototype, "list").resolves([]); + + const res = await CopilotPluginHelper.listOperations( + context, + undefined, + "", + inputs, + true, + false, + "" + ); + expect(res.isOk()).to.be.true; + }); }); diff --git a/packages/fx-core/tests/question/create.test.ts b/packages/fx-core/tests/question/create.test.ts index ccc6571719..048aa9f530 100644 --- a/packages/fx-core/tests/question/create.test.ts +++ b/packages/fx-core/tests/question/create.test.ts @@ -17,7 +17,7 @@ import { ok, } from "@microsoft/teamsfx-api"; import axios from "axios"; -import { assert } from "chai"; +import { assert, expect } from "chai"; import fs from "fs-extra"; import "mocha"; import mockedEnv, { RestoreFn } from "mocked-env"; @@ -1841,6 +1841,245 @@ describe("scaffold question", () => { assert.isUndefined(res); }); + it(" validate operations successfully with Teams AI project", async () => { + const question = apiOperationQuestion(); + const inputs: Inputs = { + platform: Platform.VSCode, + "custom-copilot-rag": "custom-copilot-rag-customApi", + [QuestionNames.ApiSpecLocation]: "apispec", + supportedApisFromApiSpec: [ + { + id: "operation1", + label: "operation1", + groupName: "1", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation2", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation3", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation4", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation5", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation6", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation7", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation8", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation9", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation10", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation11", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + ], + }; + + const validationSchema = question.validation as FuncValidation; + const res = await validationSchema.validFunc!( + [ + "operation1", + "operation2", + "operation3", + "operation4", + "operation5", + "operation6", + "operation7", + "operation8", + "operation9", + "operation10", + "operation11", + ], + inputs + ); + + assert.isUndefined(res); + }); + + it(" validate operations successfully due to length limitation", async () => { + const question = apiOperationQuestion(); + const inputs: Inputs = { + platform: Platform.VSCode, + [QuestionNames.ApiSpecLocation]: "apispec", + supportedApisFromApiSpec: [ + { + id: "operation1", + label: "operation1", + groupName: "1", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation2", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation3", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation4", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation5", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation6", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation7", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation8", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation9", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation10", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + { + id: "operation11", + label: "operation2", + groupName: "2", + data: { + serverUrl: "https://server1", + }, + }, + ], + }; + + const validationSchema = question.validation as FuncValidation; + const res = await validationSchema.validFunc!( + [ + "operation1", + "operation2", + "operation3", + "operation4", + "operation5", + "operation6", + "operation7", + "operation8", + "operation9", + "operation10", + "operation11", + ], + inputs + ); + + expect(res).to.equal( + "11 API(s) selected. You can select at least one and at most 10 APIs." + ); + }); + it(" validate operations with auth successfully", async () => { mockedEnvRestore = mockedEnv({ [FeatureFlagName.ApiKey]: "true", diff --git a/templates/js/custom-copilot-rag-custom-api/src/app/app.js.tpl b/templates/js/custom-copilot-rag-custom-api/src/app/app.js.tpl index bbf0806805..40b53a6e8c 100644 --- a/templates/js/custom-copilot-rag-custom-api/src/app/app.js.tpl +++ b/templates/js/custom-copilot-rag-custom-api/src/app/app.js.tpl @@ -38,7 +38,7 @@ const app = new Application({ }, }); -const generateAdaptiveCard = require("./utility.js"); +const { generateAdaptiveCard, addAuthConfig } = require("./utility.js"); const yaml = require("js-yaml"); const { OpenAPIClientAxios } = require("openapi-client-axios"); const fs = require("fs-extra"); diff --git a/templates/js/custom-copilot-rag-custom-api/src/app/utility.js b/templates/js/custom-copilot-rag-custom-api/src/app/utility.js index 7d5266edf9..74b79ba94d 100644 --- a/templates/js/custom-copilot-rag-custom-api/src/app/utility.js +++ b/templates/js/custom-copilot-rag-custom-api/src/app/utility.js @@ -12,4 +12,29 @@ function generateAdaptiveCard(templatePath, result) { const card = CardFactory.adaptiveCard(cardContent); return card; } -module.exports = generateAdaptiveCard; + +function addAuthConfig(client) { + // This part is sample code for adding authentication to the client. + // Please replace it with your own authentication logic. + // Please refer to https://openapistack.co/docs/openapi-client-axios/intro/ for more info about the client. + /* + client.interceptors.request.use((config) => { + // You can specify different authentication methods for different urls and methods. + if (config.url == "your-url" && config.method == "your-method") { + // You can update the target url + config.url = "your-new-url"; + + // For Basic Authentication + config.headers["Authorization"] = `Basic ${btoa("Your-Username:Your-Password")}`; + + // For Cookie + config.headers["Cookie"] = `Your-Cookie`; + + // For Bearer Token + config.headers["Authorization"] = `Bearer "Your-Token"`; + } + return config; + }); + */ +} +module.exports = { generateAdaptiveCard, addAuthConfig }; diff --git a/templates/ts/custom-copilot-rag-custom-api/src/app/app.ts.tpl b/templates/ts/custom-copilot-rag-custom-api/src/app/app.ts.tpl index efb64db0ec..4893014ba0 100644 --- a/templates/ts/custom-copilot-rag-custom-api/src/app/app.ts.tpl +++ b/templates/ts/custom-copilot-rag-custom-api/src/app/app.ts.tpl @@ -37,7 +37,7 @@ const app = new Application({ }, }); -import { generateAdaptiveCard } from "./utility"; +import { generateAdaptiveCard, addAuthConfig } from "./utility"; import { TurnContext, ConversationState } from "botbuilder"; import { TurnState, Memory } from "@microsoft/teams-ai"; import yaml from "js-yaml"; diff --git a/templates/ts/custom-copilot-rag-custom-api/src/app/utility.ts b/templates/ts/custom-copilot-rag-custom-api/src/app/utility.ts index f14c93e5d5..00ae6c1c61 100644 --- a/templates/ts/custom-copilot-rag-custom-api/src/app/utility.ts +++ b/templates/ts/custom-copilot-rag-custom-api/src/app/utility.ts @@ -1,5 +1,6 @@ import { CardFactory } from "botbuilder"; const ACData = require("adaptivecards-templating"); +import { OpenAPIClient } from "openapi-client-axios"; export function generateAdaptiveCard(templatePath: string, result: any) { if (!result || !result.data) { throw new Error("Get empty result from api call."); @@ -12,3 +13,28 @@ export function generateAdaptiveCard(templatePath: string, result: any) { const card = CardFactory.adaptiveCard(cardContent); return card; } + +export function addAuthConfig(client: OpenAPIClient) { + // This part is sample code for adding authentication to the client. + // Please replace it with your own authentication logic. + // Please refer to https://openapistack.co/docs/openapi-client-axios/intro/ for more info about the client. + /* + client.interceptors.request.use((config) => { + // You can specify different authentication methods for different urls and methods. + if (config.url == "your-url" && config.method == "your-method") { + // You can update the target url + config.url = "your-new-url"; + + // For Basic Authentication + config.headers["Authorization"] = `Basic ${btoa("Your-Username:Your-Password")}`; + + // For Cookie + config.headers["Cookie"] = `Your-Cookie`; + + // For Bearer Token + config.headers["Authorization"] = `Bearer "Your-Token"`; + } + return config; + }); + */ +}