Repository: microsoft/kiota (csharp)

Issue:
Skip adaptive card template generation when adaptive card information has empty content
### Overview

When a valid response from content is returned and no adaptive card information is provided by default it is created a json content with a valid template.
For integration scenario coming from typespec OpenAPI output, it is expected to provide a mechanism to avoid creation of this template.

This mechanism could be accomplished if found adaptive card extension information with empty content, similar to this:

```yaml
    put:
      summary: Put repair objet
      description: Returns a list of repairs with their details and images
      x-ai-adaptive-card: {}
      responses:
        "200":
          description: A new repair
          content:
            application/json:
              schema:
                type: object
```

Pull request description:
It closes #6556

Patch:
diff --git a/src/Kiota.Builder/OpenApiExtensions/OpenApiAiAdaptiveCardExtension.cs b/src/Kiota.Builder/OpenApiExtensions/OpenApiAiAdaptiveCardExtension.cs
index c61f3210bd..22f3cf2c41 100644
--- a/src/Kiota.Builder/OpenApiExtensions/OpenApiAiAdaptiveCardExtension.cs
+++ b/src/Kiota.Builder/OpenApiExtensions/OpenApiAiAdaptiveCardExtension.cs
@@ -34,7 +34,10 @@ public string? Url
     }
     public static OpenApiAiAdaptiveCardExtension Parse(JsonNode source)
     {
-        if (source is not JsonObject rawObject) throw new ArgumentOutOfRangeException(nameof(source));
+        // We are supporting empty extension to avoid creating the template when emitting from typespec scenario
+        var emptyExtension = new OpenApiAiAdaptiveCardExtension();
+        if (source is not JsonObject rawObject)
+            return emptyExtension;
         var extension = new OpenApiAiAdaptiveCardExtension();
         if (rawObject.TryGetPropertyValue(nameof(DataPath).ToFirstCharacterLowerCase().ToSnakeCase(), out var dataPath) && dataPath is JsonValue dataPathValue && dataPathValue.GetValueKind() is JsonValueKind.String && dataPathValue.TryGetValue<string>(out var dataPathStrValue))
         {
@@ -52,8 +55,9 @@ public static OpenApiAiAdaptiveCardExtension Parse(JsonNode source)
         {
             extension.Url = urlStrValue;
         }
+        // We are supporting empty extension to avoid creating the template when emitting from typespec scenario
         if (string.IsNullOrEmpty(extension.DataPath) || string.IsNullOrEmpty(extension.File) || string.IsNullOrEmpty(extension.Title))
-            throw new ArgumentOutOfRangeException(nameof(source), "The properties 'x-ai-adaptive-card.dataPath', 'x-ai-adaptive-card.file' and 'x-ai-adaptive-card.title' must be set.");
+            return emptyExtension;
         return extension;
     }
 
diff --git a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs
index 3bcc6f5a27..c7b54cc81b 100644
--- a/src/Kiota.Builder/Plugins/PluginsGenerationService.cs
+++ b/src/Kiota.Builder/Plugins/PluginsGenerationService.cs
@@ -1108,15 +1108,24 @@ rExtRaw is T rExt &&
             return capabilities;
         }
 
-
-        var responseSemantics = GetResponseSemanticsFromAdaptiveCardExtension(openApiOperation, OpenApiAiAdaptiveCardExtension.Name) ??
-            GetResponseSemanticsFromTemplate(openApiOperation, configuration, logger);
-
-        return new FunctionCapabilities
+        var responseSemantics = GetResponseSemanticsFromAdaptiveCardExtension(openApiOperation, OpenApiAiAdaptiveCardExtension.Name);
+        if (responseSemantics != null)
         {
-            ResponseSemantics = responseSemantics
-        };
+            return new FunctionCapabilities
+            {
+                ResponseSemantics = responseSemantics
+            };
+        }
 
+        var responseSemanticsFromTemplate = GetResponseSemanticsFromTemplate(openApiOperation, configuration, logger);
+        if (responseSemanticsFromTemplate != null)
+        {
+            return new FunctionCapabilities
+            {
+                ResponseSemantics = responseSemanticsFromTemplate
+            };
+        }
+        return null;
     }
 
     private static FunctionCapabilities? GetFunctionCapabilitiesFromCapabilitiesExtension(OpenApiOperation openApiOperation, string extensionName)
@@ -1187,6 +1196,12 @@ rExtRaw is T rExt &&
         if (openApiOperation.Extensions is not null &&
             openApiOperation.Extensions.TryGetValue(extensionName, out var adaptiveCardExtension) && adaptiveCardExtension is OpenApiAiAdaptiveCardExtension adaptiveCard)
         {
+            // This is a workaround for integration with TypeSpec when passing empty object from adaptiveCardExtension
+            if (string.IsNullOrEmpty(adaptiveCard.DataPath) || string.IsNullOrEmpty(adaptiveCard.File) || string.IsNullOrEmpty(adaptiveCard.Title))
+            {
+                return null;
+            }
+
             JsonNode node = new JsonObject();
             node["file"] = JsonValue.Create(adaptiveCard.File);
             using JsonDocument doc = JsonDocument.Parse(node.ToJsonString());
@@ -1216,6 +1231,16 @@ rExtRaw is T rExt &&
             return null;
         }
 
+        // This is a workaround for integration with TypeSpec when passing empty object from adaptiveCardExtension
+        if (openApiOperation.Extensions is not null &&
+    openApiOperation.Extensions.TryGetValue(OpenApiAiAdaptiveCardExtension.Name, out var adaptiveCardExtension) && adaptiveCardExtension is OpenApiAiAdaptiveCardExtension adaptiveCard)
+        {
+            if (string.IsNullOrEmpty(adaptiveCard.DataPath) || string.IsNullOrEmpty(adaptiveCard.File) || string.IsNullOrEmpty(adaptiveCard.Title))
+            {
+                return null;
+            }
+        }
+
         string functionName = openApiOperation.OperationId;
         string fileName = $"{functionName}.json";
         string staticTemplateJson = $"{{\"file\": \"./adaptiveCards/{fileName}\"}}";


Test patch:
diff --git a/tests/Kiota.Builder.IntegrationTests/ModelWithAdaptiveCardExtension.yaml b/tests/Kiota.Builder.IntegrationTests/ModelWithAdaptiveCardExtension.yaml
index 7680dc314d..4727f74f53 100644
--- a/tests/Kiota.Builder.IntegrationTests/ModelWithAdaptiveCardExtension.yaml
+++ b/tests/Kiota.Builder.IntegrationTests/ModelWithAdaptiveCardExtension.yaml
@@ -14,8 +14,6 @@ paths:
         data_path: $.test
         file: path_to_adaptive_card_file
         title: listRepairs
-      servers:
-        - url: https://sample.server.overridden/api
       responses:
         "200":
           description: A list of repairs
@@ -23,6 +21,17 @@ paths:
             application/json:
               schema:
                 type: object
+    put:
+      summary: Put repair objet
+      description: Returns a list of repairs with their details and images
+      x-ai-adaptive-card: {}
+      responses:
+        "200":
+          description: A new repair
+          content:
+            application/json:
+              schema:
+                type: object
     post:
       summary: Create new repair with oauth
       description: Returns the create repair
diff --git a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
index c31a2f9e5f..5294111414 100644
--- a/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
+++ b/tests/Kiota.Builder.Tests/Plugins/PluginsGenerationServiceTests.cs
@@ -418,7 +418,7 @@ public async Task GeneratesManifestWithAdaptiveCardExtensionAsync()
         using JsonDocument doc = JsonDocument.Parse(jsonString);
         JsonElement staticTemplate = doc.RootElement.Clone();
         Assert.Equal(staticTemplate.ToString(), resultingManifest.Document.Functions[0].Capabilities.ResponseSemantics.StaticTemplate.ToString());
-        Assert.Null(resultingManifest.Document.Functions[1].Capabilities.ResponseSemantics);// no response semantics is added if no adaptive card
+        Assert.Null(resultingManifest.Document.Functions[1].Capabilities);// no response semantics is added if no adaptive card
     }
 
 
@@ -521,7 +521,7 @@ public async Task GeneratesManifestWithAdaptiveCardWithoutExtensionAsync()
         Assert.NotNull(resultingManifest.Document);
         Assert.Equal(OpenApiFileName, resultingManifest.Document.Runtimes.OfType<OpenApiRuntime>().First().Spec.Url);
         Assert.Equal(2, resultingManifest.Document.Functions.Count);// all functions are generated despite missing operationIds
-        Assert.Null(resultingManifest.Document.Functions[0].Capabilities.ResponseSemantics); // no response semantics is added if no schema
+        Assert.Null(resultingManifest.Document.Functions[0].Capabilities); // no response semantics is added if no schema
         Assert.NotNull(resultingManifest.Document.Functions[1].Capabilities.ResponseSemantics); // response semantics is added if response has schema
         string jsonString = $"{{\"file\": \"./adaptiveCards/{resultingManifest.Document.Functions[1].Name}.json\"}}";
         using JsonDocument doc = JsonDocument.Parse(jsonString);
@@ -534,6 +534,101 @@ public async Task GeneratesManifestWithAdaptiveCardWithoutExtensionAsync()
 
     }
 
+    [Fact]
+    public async Task GeneratesManifestWithEmptyAdaptiveCardExtensionAsync()
+    {
+        var simpleDescriptionContent = @"openapi: 3.0.0
+info:
+  title: test
+  version: 1.0
+servers:
+  - url: http://localhost/
+    description: There's no place like home
+paths:
+  /test/{id}:
+    get:
+      summary: description for test path with id
+      operationId: test.WithId
+      x-ai-adaptive-card: {}
+      parameters:
+      - name: id
+        in: path
+        required: true
+        description: The id of the test
+        schema:
+          type: integer
+          format: int32
+      responses:
+        '200':
+          description:
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/microsoft.graph.message'
+        '500':
+          description: api error response
+components:
+  schemas:
+    microsoft.graph.entity:
+      title: entity
+      required:
+        - '@odata.type'
+      type: object
+      properties:
+        id:
+          anyOf:
+          - type: string
+          - type: integer
+        '@odata.type':
+          type: string
+    microsoft.graph.message:
+      allOf:
+      - $ref: '#/components/schemas/microsoft.graph.entity'
+      - type: object
+        title: message
+        properties:
+          subject:
+            type: string
+          body:
+            type: string";
+
+        var workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+        var simpleDescriptionPath = Path.Combine(workingDirectory) + "description.yaml";
+        await File.WriteAllTextAsync(simpleDescriptionPath, simpleDescriptionContent);
+        var openAPIDocumentDS = new OpenApiDocumentDownloadService(_httpClient, _logger);
+        var outputDirectory = Path.Combine(workingDirectory, "output");
+        var generationConfiguration = new GenerationConfiguration
+        {
+            OutputPath = outputDirectory,
+            OpenAPIFilePath = simpleDescriptionPath,
+            PluginTypes = [PluginType.APIPlugin],
+            ClientClassName = "client",
+            ApiRootUrl = "http://localhost/", //Kiota builder would set this for us
+        };
+        var (openAPIDocumentStream, _) = await openAPIDocumentDS.LoadStreamAsync(simpleDescriptionPath, generationConfiguration, null, false);
+        var openApiDocument = await openAPIDocumentDS.GetDocumentFromStreamAsync(openAPIDocumentStream, generationConfiguration);
+        KiotaBuilder.CleanupOperationIdForPlugins(openApiDocument);
+        var urlTreeNode = OpenApiUrlTreeNode.Create(openApiDocument, Constants.DefaultOpenApiLabel);
+
+        var pluginsGenerationService = new PluginsGenerationService(openApiDocument, urlTreeNode, generationConfiguration, workingDirectory, _logger);
+        await pluginsGenerationService.GenerateManifestAsync();
+
+        Assert.True(File.Exists(Path.Combine(outputDirectory, ManifestFileName)));
+        Assert.True(File.Exists(Path.Combine(outputDirectory, OpenApiFileName)));
+
+        // Validate the v2 plugin
+        var manifestContent = await File.ReadAllTextAsync(Path.Combine(outputDirectory, ManifestFileName));
+        using var jsonDocument = JsonDocument.Parse(manifestContent);
+        var resultingManifest = PluginManifestDocument.Load(jsonDocument.RootElement);
+        Assert.NotNull(resultingManifest.Document);
+        Assert.Equal(OpenApiFileName, resultingManifest.Document.Runtimes.OfType<OpenApiRuntime>().First().Spec.Url);
+        Assert.Single(resultingManifest.Document.Functions);// all functions are generated despite missing operationIds
+        Assert.Null(resultingManifest.Document.Functions[0].Capabilities); // response semantics is added if response has schema but it is not if empty adaptive card extension is present
+        // validate adaptive card does not exist
+        var path = Path.Combine(outputDirectory, "adaptiveCards", $"{resultingManifest.Document.Functions[0].Name}.json");
+        Assert.False(File.Exists(path));
+    }
+
     [Fact]
     public async Task GeneratesManifestWithDefault200ResponseAsync()
     {
@@ -1515,7 +1610,7 @@ public async Task GeneratesManifestWithAiCapabilitiesExtensionAsync()
         Assert.Contains("personalData", resultingManifest.Document.Functions[0].Capabilities.SecurityInfo.DataHandling);
 
         // Second function has no response semantics
-        Assert.Null(resultingManifest.Document.Functions[1].Capabilities.ResponseSemantics);
+        Assert.Null(resultingManifest.Document.Functions[1].Capabilities);
     }
 
     [Fact]
diff --git a/vscode/npm-package/tests/integration/integrationGeneratePlugin.spec.ts b/vscode/npm-package/tests/integration/integrationGeneratePlugin.spec.ts
index 9749521789..fb4c3ffb66 100644
--- a/vscode/npm-package/tests/integration/integrationGeneratePlugin.spec.ts
+++ b/vscode/npm-package/tests/integration/integrationGeneratePlugin.spec.ts
@@ -1,3 +1,4 @@
+import fs from 'fs';
 import path from 'path';
 import { generatePlugin } from '../../lib/generatePlugin';
 import { getKiotaTree } from '../../lib/getKiotaTree';
@@ -6,6 +7,14 @@ import { KiotaPluginType, ConsumerOperation, LogLevel } from '../../types';
 import { PluginAuthType } from '../../types';
 import { existsEqualOrGreaterThanLevelLogs } from '../assertUtils';
 
+async function loadJSON(filename: string) {
+  const json = await import(filename, {
+    with: { type: 'json' },
+  });
+
+  return json.default;
+}
+
 describe("GeneratePlugin", () => {
 
   test('generatePlugin_withoutWorkspaceAddOperationForExisting', async () => {
@@ -147,6 +156,92 @@ describe("GeneratePlugin", () => {
     expect(actualApiManifest).toBeDefined();
   });
 
+  test('generatePlugin_withAdaptiveCardExtension', async () => {
+    const descriptionUrl = '../../tests/Kiota.Builder.IntegrationTests/ModelWithAdaptiveCardExtension.yaml';
+    const outputPath = './.tests_output';
+
+    const pluginType = KiotaPluginType.ApiPlugin;
+    const actual = await generatePlugin({
+      descriptionPath: descriptionUrl,
+      outputPath: outputPath,
+      clearCache: false,
+      pluginType: pluginType,
+      pluginName: 'withAdaptiveCardExtension',
+      operation: ConsumerOperation.Generate,
+      workingDirectory: '',
+      noWorkspace: true,
+    });
+    expect(actual).toBeDefined();
+    expect(existsEqualOrGreaterThanLevelLogs(actual?.logs, LogLevel.warning)).toBeFalsy();
+    expect(existsEqualOrGreaterThanLevelLogs(actual?.logs, LogLevel.information)).toBeTruthy();
+
+    if (!actual?.aiPlugin) {
+      throw new Error('aiPlugin should be defined');
+    }
+    const actualPluginManifest = await getPluginManifest({
+      descriptionPath: actual?.aiPlugin
+    });
+    expect(actualPluginManifest).toBeDefined();
+
+    if (!actual?.openAPISpec) {
+      throw new Error('descriptionPath should be defined');
+    }
+    const actualApiManifest = await getKiotaTree({
+      descriptionPath: actual?.openAPISpec,
+    });
+    expect(actualApiManifest).toBeDefined();
+
+    // Load json from file path
+    const pluginJsonPath = actual?.aiPlugin;
+    if (!pluginJsonPath) {
+      throw new Error('aiPlugin should be defined');
+    }
+
+    // add ./ to the path to make it relative
+    const relativePath = path.relative(__dirname, pluginJsonPath);
+
+    const pluginJson = await loadJSON(relativePath);
+    expect(pluginJson).toBeDefined();
+
+    // listRepairs should have capabilities defined using content from adaptive card extension information
+    const firstFunction = pluginJson.functions.find((f: { name: string }) => f.name === 'listRepairs');
+    expect(firstFunction).toBeDefined();
+    expect(firstFunction.capabilities).toBeDefined();
+    expect(firstFunction.capabilities.response_semantics).toBeDefined();
+    expect(firstFunction.capabilities.response_semantics.data_path).toEqual('$.test');
+    expect(firstFunction.capabilities.response_semantics.static_template).toBeDefined();
+    expect(firstFunction.capabilities.response_semantics.static_template.file).toEqual('path_to_adaptive_card_file');
+
+    // Check that this file does not exist since it is sample path coming from adaptive card extension, so template is not generated
+    const firstFunctionStaticTemplateRelativePath = path.relative(__dirname, '.tests_output/path_to_adaptive_card_file');
+    expect(firstFunctionStaticTemplateRelativePath).toBeDefined();
+    const resolvedFirstFunctionPath = path.resolve(__dirname, firstFunctionStaticTemplateRelativePath);
+    const firstFunctionFileExists = fs.existsSync(resolvedFirstFunctionPath);
+    expect(firstFunctionFileExists).toBeFalsy();
+
+    // post should have capabilities defined using template
+    const secondFunction = pluginJson.functions.find((f: { name: string }) => f.name === 'repairs_post');
+    expect(secondFunction).toBeDefined();
+    expect(secondFunction.capabilities).toBeDefined();
+    expect(secondFunction.capabilities.response_semantics).toBeDefined();
+    expect(secondFunction.capabilities.response_semantics.data_path).toEqual('$');
+    expect(secondFunction.capabilities.response_semantics.static_template).toBeDefined();
+    expect(secondFunction.capabilities.response_semantics.static_template.file).toEqual('./adaptiveCards/repairs_post.json');
+    
+    // Check that this file exists
+    const secondFunctionStaticTemplateRelativePath = path.relative(__dirname, '.tests_output/adaptiveCards/repairs_post.json');
+    expect(secondFunctionStaticTemplateRelativePath).toBeDefined();
+    const resolvedSecondFunctionPath = path.resolve(__dirname, secondFunctionStaticTemplateRelativePath);
+    const secondFunctionFileExists = fs.existsSync(resolvedSecondFunctionPath);
+    expect(secondFunctionFileExists).toBeTruthy();
+
+    // put should not have capabilities defined since has empty adaptive card extension content to support typespec scenarios
+    const thirdFunction = pluginJson.functions.find((f: { name: string }) => f.name === 'repairs_put');
+    expect(thirdFunction).toBeDefined();
+    expect(thirdFunction.capabilities).toBeUndefined();
+
+  });
+
   test('generatePlugin_withSecurity', async () => {
     const descriptionUrl = '../../tests/Kiota.Builder.IntegrationTests/ModelWithSecurity.yaml';
     const outputPath = './.tests_output';