Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: add auth support for custom api #11084

Merged
merged 4 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
ProjectType,
ParseOptions,
AdaptiveCardGenerator,
Utils,
} from "@microsoft/m365-spec-parser";
import fs from "fs-extra";
import { getLocalizedString } from "../../../common/localizeUtils";
Expand All @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -182,6 +185,10 @@ export async function listOperations(
apiSpecUrl as string,
isPlugin
? copilotPluginParserOptions
: isCustomApi
? {
projectType: ProjectType.TeamsAi,
}
: {
allowAPIKeyAuth,
allowMultipleParameters,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
});
}
}
Expand All @@ -743,7 +758,7 @@ function parseSpec(spec: OpenAPIV3.Document): SpecObject[] {
}
}

return res;
return [res, needAuth];
}

async function updatePromptForCustomApi(
Expand Down Expand Up @@ -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, {
Expand All @@ -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, {
Expand All @@ -866,22 +883,38 @@ 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<void> {
if (language === ProgrammingLanguage.JS || language === ProgrammingLanguage.TS) {
const codeTemplate =
ActionCode[language === ProgrammingLanguage.JS ? "javascript" : "typescript"];
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);
}
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
6 changes: 5 additions & 1 deletion packages/fx-core/src/question/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
});
});
Loading
Loading