From 2602d567195599fe7de42ddeb08c7ddf1b25c80c Mon Sep 17 00:00:00 2001 From: ricwilson Date: Thu, 15 May 2025 13:40:27 -0400 Subject: [PATCH 01/24] Add PowerPlatformSpecGeneratorPlugin and configuration schema - Implemented the PowerPlatformSpecGeneratorPlugin to generate OpenAPI specifications from recorded requests. - Added configuration options including inclusion of OPTIONS requests, specification format (JSON/YAML), and contact/connector metadata. - Created a JSON schema for validating the plugin's configuration settings. --- .../PowerPlatformSpecGeneratorPlugin.cs | 1270 +++++++++++++++++ ...werplatformspecgeneratorplugin.schema.json | 70 + 2 files changed, 1340 insertions(+) create mode 100644 dev-proxy-plugins/RequestLogs/PowerPlatformSpecGeneratorPlugin.cs create mode 100644 schemas/v0.27.0/powerplatformspecgeneratorplugin.schema.json diff --git a/dev-proxy-plugins/RequestLogs/PowerPlatformSpecGeneratorPlugin.cs b/dev-proxy-plugins/RequestLogs/PowerPlatformSpecGeneratorPlugin.cs new file mode 100644 index 00000000..b2c5c9ee --- /dev/null +++ b/dev-proxy-plugins/RequestLogs/PowerPlatformSpecGeneratorPlugin.cs @@ -0,0 +1,1270 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.Configuration; +using DevProxy.Abstractions; +using Titanium.Web.Proxy.EventArguments; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Extensions; +using System.Text.Json; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Writers; +using Microsoft.OpenApi; +using Titanium.Web.Proxy.Http; +using System.Web; +using System.Collections.Specialized; +using Microsoft.Extensions.Logging; +using DevProxy.Abstractions.LanguageModel; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.OpenApi.Any; + +namespace DevProxy.Plugins.RequestLogs; + +public class PowerPlatformSpecGeneratorPluginReportItem +{ + public required string ServerUrl { get; init; } + public required string FileName { get; init; } +} + +public class PowerPlatformSpecGeneratorPluginReport : List +{ + public PowerPlatformSpecGeneratorPluginReport() : base() { } + + public PowerPlatformSpecGeneratorPluginReport(IEnumerable collection) : base(collection) { } +} + +internal class PowerPlatformSpecGeneratorPluginConfiguration +{ + public bool IncludeOptionsRequests { get; set; } = false; + + public SpecFormat SpecFormat { get; set; } = SpecFormat.Json; + + public bool IncludeResponseHeaders { get; set; } = false; + + public ContactConfig? Contact { get; set; } + + public ConnectorMetadataConfig? ConnectorMetadata { get; set; } +} + +public class ContactConfig +{ + public string Name { get; set; } = "Your Name"; + public string Url { get; set; } = "https://www.yourwebsite.com"; + public string Email { get; set; } = "your.email@yourdomain.com"; +} + +public class ConnectorMetadataConfig +{ + public string? Website { get; set; } + public string? PrivacyPolicy { get; set; } + public string? Categories { get; set; } +} + +public class PowerPlatformSpecGeneratorPlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReportingPlugin(pluginEvents, context, logger, urlsToWatch, configSection) +{ + public override string Name => nameof(PowerPlatformSpecGeneratorPlugin); + private readonly PowerPlatformSpecGeneratorPluginConfiguration _configuration = new(); + public static readonly string GeneratedPowerPlatformSpecsKey = "GeneratedPowerPlatformSpecs"; + + public override async Task RegisterAsync() + { + await base.RegisterAsync(); + + ConfigSection?.Bind(_configuration); + + PluginEvents.AfterRecordingStop += AfterRecordingStopAsync; + } + + private async Task AfterRecordingStopAsync(object? sender, RecordingArgs e) + { + Logger.LogInformation("Creating Power Platform spec from recorded requests..."); + + if (!e.RequestLogs.Any()) + { + Logger.LogDebug("No requests to process"); + return; + } + + var openApiDocs = new List(); + + foreach (var request in e.RequestLogs) + { + if (request.MessageType != MessageType.InterceptedResponse || + request.Context is null || + request.Context.Session is null || + !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)) + { + continue; + } + + if (!_configuration.IncludeOptionsRequests && + string.Equals(request.Context.Session.HttpClient.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("Skipping OPTIONS request {url}...", request.Context.Session.HttpClient.Request.RequestUri); + continue; + } + + var methodAndUrlString = request.Message.First(); + Logger.LogDebug("Processing request {methodAndUrlString}...", methodAndUrlString); + + try + { + var pathItem = await GetOpenApiPathItem(request.Context.Session); + var parametrizedPath = ParametrizePath(pathItem, request.Context.Session.HttpClient.Request.RequestUri); + var operationInfo = pathItem.Operations.First(); + operationInfo.Value.OperationId = await GetOperationIdAsync( + operationInfo.Key.ToString(), + request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), + parametrizedPath + ); + operationInfo.Value.Description = await GetOperationDescriptionAsync( + operationInfo.Key.ToString(), + request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), + parametrizedPath + ); + operationInfo.Value.Summary = await GetOperationSummaryAsync( + operationInfo.Key.ToString(), + request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), + parametrizedPath + ); + await AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error processing request {methodAndUrl}", methodAndUrlString); + } + } + + Logger.LogDebug("Serializing OpenAPI docs..."); + var generatedOpenApiSpecs = new Dictionary(); + foreach (var openApiDoc in openApiDocs) + { + var server = openApiDoc.Servers.First(); + var fileName = GetFileNameFromServerUrl(server.Url, _configuration.SpecFormat); + + var openApiSpecVersion = OpenApiSpecVersion.OpenApi2_0; + + var docString = _configuration.SpecFormat switch + { + SpecFormat.Json => openApiDoc.SerializeAsJson(openApiSpecVersion), + SpecFormat.Yaml => openApiDoc.SerializeAsYaml(openApiSpecVersion), + _ => openApiDoc.SerializeAsJson(openApiSpecVersion) + }; + + Logger.LogDebug(" Writing OpenAPI spec to {fileName}...", fileName); + File.WriteAllText(fileName, docString); + + generatedOpenApiSpecs.Add(server.Url, fileName); + + Logger.LogInformation("Created Power Platform spec file {fileName}", fileName); + } + + StoreReport(new PowerPlatformSpecGeneratorPluginReport( + generatedOpenApiSpecs + .Select(kvp => new PowerPlatformSpecGeneratorPluginReportItem + { + ServerUrl = kvp.Key, + FileName = kvp.Value + })), e); + + // store the generated OpenAPI specs in the global data + // for use by other plugins + e.GlobalData[GeneratedPowerPlatformSpecsKey] = generatedOpenApiSpecs; + } + + /** + * Replaces segments in the request URI, that match predefined patters, + * with parameters and adds them to the OpenAPI PathItem. + * @param pathItem The OpenAPI PathItem to parametrize. + * @param requestUri The request URI. + * @returns The parametrized server-relative URL + */ + private static string ParametrizePath(OpenApiPathItem pathItem, Uri requestUri) + { + var segments = requestUri.Segments; + var previousSegment = "item"; + + for (var i = 0; i < segments.Length; i++) + { + var segment = requestUri.Segments[i].Trim('/'); + if (string.IsNullOrEmpty(segment)) + { + continue; + } + + if (IsParametrizable(segment)) + { + var parameterName = $"{previousSegment}-id"; + segments[i] = $"{{{parameterName}}}{(requestUri.Segments[i].EndsWith('/') ? "/" : "")}"; + + pathItem.Parameters.Add(new OpenApiParameter + { + Name = parameterName, + In = ParameterLocation.Path, + Required = true, + Schema = new OpenApiSchema { Type = "string" } + }); + } + else + { + previousSegment = segment; + } + } + + return string.Join(string.Empty, segments); + } + + private static bool IsParametrizable(string segment) + { + return Guid.TryParse(segment.Trim('/'), out _) || + int.TryParse(segment.Trim('/'), out _); + } + + private static string GetLastNonTokenSegment(string[] segments) + { + for (var i = segments.Length - 1; i >= 0; i--) + { + var segment = segments[i].Trim('/'); + if (string.IsNullOrEmpty(segment)) + { + continue; + } + + if (!IsParametrizable(segment)) + { + return segment; + } + } + + return "item"; + } + + private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) + { + var prompt = @"**Prompt:** + Generate an operation ID for an OpenAPI specification based on the HTTP method and URL provided. Follow these rules: + - The operation ID should be in camelCase format. + - Start with a verb that matches the HTTP method (e.g., `get`, `create`, `update`, `delete`). + - Use descriptive words from the URL path. + - Replace path parameters (e.g., `{userId}`) with relevant nouns in singular form (e.g., `User`). + - Do not provide explanations or any other text; respond only with the operation ID. + + Example: + **Request:** `GET https://api.contoso.com/books/{books-id}` + getBook + + Example: + **Request:** `GET https://api.contoso.com/books/{books-id}/authors` + getBookAuthors + + Example: + **Request:** `GET https://api.contoso.com/books/{books-id}/authors/{authors-id}` + getBookAuthor + + Example: + **Request:** `POST https://api.contoso.com/books/{books-id}/authors` + addBookAuthor + + Now, generate the operation ID for the following: + **Request:** `{request}`".Replace("{request}", $"{method.ToUpper()} {serverUrl}{parametrizedPath}"); + ILanguageModelCompletionResponse? id = null; + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + id = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 1 }); + } + return id?.Response?.Trim() ?? $"{method}{parametrizedPath.Replace('/', '.')}"; + } + + private async Task GetOperationSummaryAsync(string method, string serverUrl, string parametrizedPath) + { + var prompt = $@"You're an expert in OpenAPI. + You help developers build great OpenAPI specs for use with LLMs. + For the specified request, generate a concise, one-sentence summary that adheres to the following rules: + - Must exist and be written in English. + - Must be a phrase and cannot not end with punctuation. + - Must be free of grammatical and spelling errors. + - Must be 80 characters or less. + - Must contain only alphanumeric characters or parentheses. + - Must not include the words API, Connector, or any other Power Platform product names (for example, Power Apps). + - Respond with just the summary. + + For example: + - For a request such as `GET https://api.contoso.com/books/{{books-id}}`, return `Get a book by ID` + - For a request such as `POST https://api.contoso.com/books`, return `Create a new book` + + Request: {method.ToUpper()} {serverUrl}{parametrizedPath}"; + ILanguageModelCompletionResponse? description = null; + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + description = await Context.LanguageModelClient.GenerateCompletionAsync(prompt); + } + return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; + } + + private async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath) + { + var prompt = $@"You're an expert in OpenAPI. + You help developers build great OpenAPI specs for use with LLMs. + For the specified request, generate a one-sentence description that ends in punctuation. + Respond with just the description. + For example, for a request such as `GET https://api.contoso.com/books/{{books-id}}` + // you return `Get a book by ID`. Request: {method.ToUpper()} {serverUrl}{parametrizedPath}"; + ILanguageModelCompletionResponse? description = null; + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + description = await Context.LanguageModelClient.GenerateCompletionAsync(prompt); + } + return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; + } + + private async Task GenerateParameterDescriptionAsync(string parameterName, ParameterLocation location) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. + The description must adhere to the following rules: + - Must exist and be written in English. + - Must be a full, descriptive sentence, and end in punctuation. + - Must be free of grammatical and spelling errors. + - Must describe the purpose of the parameter and its role in the request. + - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). + + Parameter Metadata: + - Name: {parameterName} + - Location: {location} + + Examples: + - For a query parameter named 'filter', return: 'Specifies a filter to narrow results.' + - For a path parameter named 'userId', return: 'Specifies the user ID to retrieve details.' + + Now, generate the description for this parameter."; + + ILanguageModelCompletionResponse? response = null; + + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to the default logic if the language model fails or returns no response + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetFallbackParameterDescription(parameterName, location); + } + + private async Task GenerateParameterSummaryAsync(string parameterName, ParameterLocation location) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. + The summary must adhere to the following rules: + - Must exist and be written in English. + - Must be free of grammatical and spelling errors. + - Must be 80 characters or less. + - Must contain only alphanumeric characters or parentheses. + - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). + + Parameter Metadata: + - Name: {parameterName} + - Location: {location} + + Examples: + - For a query parameter named 'filter', return: 'Filter results by a specific value.' + - For a path parameter named 'userId', return: 'The unique identifier for a user.' + + Now, generate the summary for this parameter."; + + ILanguageModelCompletionResponse? response = null; + + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to a default summary if the language model fails or returns no response + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetFallbackParameterSummary(parameterName, location); + } + + private string GetFallbackParameterSummary(string parameterName, ParameterLocation location) + { + return location switch + { + ParameterLocation.Query => $"Filter results with '{parameterName}'.", + ParameterLocation.Header => $"Provide context with '{parameterName}'.", + ParameterLocation.Path => $"Identify resource with '{parameterName}'.", + ParameterLocation.Cookie => $"Manage session with '{parameterName}'.", + _ => $"Provide info with '{parameterName}'." + }; + } + + private string GetFallbackParameterDescription(string parameterName, ParameterLocation location) + { + return location switch + { + ParameterLocation.Query => $"Specifies the query parameter '{parameterName}' used to filter or modify the request.", + ParameterLocation.Header => $"Specifies the header parameter '{parameterName}' used to provide additional context or metadata.", + ParameterLocation.Path => $"Specifies the path parameter '{parameterName}' required to identify a specific resource.", + ParameterLocation.Cookie => $"Specifies the cookie parameter '{parameterName}' used for session or state management.", + _ => $"Specifies the parameter '{parameterName}' used in the request." + }; + } + + private async Task GetOpenApiDescriptionAsync(string defaultDescription) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following OpenAPI document metadata, generate a concise and descriptive summary for the API. + Include the purpose of the API and the types of operations it supports. Respond with just the description. + + OpenAPI Metadata: + - Description: {defaultDescription} + + Rules: + Must exist and be written in English. + Must be free of grammatical and spelling errors. + Should describe concisely the main purpose and value offered by your connector. + Must be longer than 30 characters and shorter than 500 characters. + Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). + + Example: + If the API is for managing books, you might respond with: + 'Allows users to manage books, including operations to create, retrieve, update, and delete book records.' + + Now, generate the description for this API."; + + ILanguageModelCompletionResponse? description = null; + + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + description = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + return description?.Response?.Trim() ?? defaultDescription; + } + + private async Task GetOpenApiTitleAsync(string defaultTitle) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following guidelines, generate a concise and descriptive title for the API. + The title must meet the following requirements: + + - Must exist and be written in English. + - Must be unique and distinguishable from any existing connector and/or plugin title. + - Should be the name of the product or organization. + - Should follow existing naming patterns for certified connectors and/or plugins. For independent publishers, the connector name should follow the pattern: Connector Name (Independent Publisher). + - Can't be longer than 30 characters. + - Can't contain the words API, Connector, Copilot Studio, or any other Power Platform product names (for example, Power Apps). + - Can't end in a nonalphanumeric character, including carriage return, new line, or blank space. + + Examples: + - Good titles: Azure Sentinel, Office 365 Outlook + - Poor titles: Azure Sentinel's Power Apps Connector, Office 365 Outlook API + + Now, generate a title for the following API: + Default Title: {defaultTitle}"; + + ILanguageModelCompletionResponse? title = null; + + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + title = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to the default title if the language model fails + return title?.Response?.Trim() ?? defaultTitle; + } + + private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the corporate website URL for the API. + If the corporate website URL cannot be determined, respond with the default URL provided. + + API Metadata: + - Default URL: {defaultUrl} + + Rules you must follow: + - Do not output any explanations or additional text. + - The URL must be a valid, publicly accessible website. + - The URL must not contain placeholders or invalid characters. + - If no corporate website URL can be determined, return the default URL. + + Example: + Default URL: https://example.com + Response: https://example.com + + Now, determine the corporate website URL for this API."; + + ILanguageModelCompletionResponse? response = null; + + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to the default URL if the language model fails or returns no response + return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; + } + + private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defaultUrl) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the privacy policy URL for the corporate website or API. + If the privacy policy URL cannot be determined, respond with the default URL provided. + + API Metadata: + - Default URL: {defaultUrl} + + Rules you must follow: + - Do not output any explanations or additional text. + - The URL must be a valid, publicly accessible website. + - The URL must not contain placeholders or invalid characters. + - If no privacy policy URL can be determined, return the default URL. + + Example: + Response: https://example.com/privacy + + Now, determine the privacy policy URL for this API."; + + ILanguageModelCompletionResponse? response = null; + + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to the default URL if the language model fails or returns no response + return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; + } + + private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, string defaultCategories) + { + var allowedCategories = @"""AI"", ""Business Management"", ""Business Intelligence"", ""Collaboration"", ""Commerce"", ""Communication"", + ""Content and Files"", ""Finance"", ""Data"", ""Human Resources"", ""Internet of Things"", ""IT Operations"", + ""Lifestyle and Entertainment"", ""Marketing"", ""Productivity"", ""Sales and CRM"", ""Security"", + ""Social Media"", ""Website"""; + + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following API metadata and the server URL, determine the most appropriate categories for the API from the allowed list of categories. + If you cannot determine appropriate categories, respond with 'None'. + + API Metadata: + - Server URL: {serverUrl} + - Allowed Categories: {allowedCategories} + + Rules you must follow: + - Do not output any explanations or additional text. + - The categories must be from the allowed list. + - The categories must be relevant to the API's functionality and purpose. + - The categories should be in a comma-separated format. + - If you cannot determine appropriate categories, respond with 'None'. + + Example: + Allowed Categories: AI, Data + Response: Data + + Now, determine the categories for this API."; + + ILanguageModelCompletionResponse? response = null; + + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // If the response is 'None' or empty, return the default categories + return !string.IsNullOrWhiteSpace(response?.Response) && response.Response.Trim() != "None" + ? response.Response + : defaultCategories; + } + + /** + * Creates an OpenAPI PathItem from an intercepted request and response pair. + * @param session The intercepted session. + */ + private async Task GetOpenApiPathItem(SessionEventArgs session) + { + var request = session.HttpClient.Request; + var response = session.HttpClient.Response; + + var resource = GetLastNonTokenSegment(request.RequestUri.Segments); + var path = new OpenApiPathItem(); + + var method = request.Method?.ToUpperInvariant() switch + { + "DELETE" => OperationType.Delete, + "GET" => OperationType.Get, + "HEAD" => OperationType.Head, + "OPTIONS" => OperationType.Options, + "PATCH" => OperationType.Patch, + "POST" => OperationType.Post, + "PUT" => OperationType.Put, + "TRACE" => OperationType.Trace, + _ => throw new NotSupportedException($"Method {request.Method} is not supported") + }; + var operation = new OpenApiOperation + { + // will be replaced later after the path has been parametrized + Description = $"{method} {resource}", + // will be replaced later after the path has been parametrized + OperationId = $"{method}.{resource}" + }; + await SetParametersFromQueryString(operation, HttpUtility.ParseQueryString(request.RequestUri.Query)); + await SetParametersFromRequestHeaders(operation, request.Headers); + await SetRequestBody(operation, request); + await SetResponseFromSession(operation, response); + + path.Operations.Add(method, operation); + + return path; + } + + private async Task SetRequestBody(OpenApiOperation operation, Request request) + { + if (!request.HasBody) + { + Logger.LogDebug(" Request has no body"); + return; + } + + if (request.ContentType is null) + { + Logger.LogDebug(" Request has no content type"); + return; + } + + Logger.LogDebug(" Processing request body..."); + operation.RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + { + GetMediaType(request.ContentType), + new OpenApiMediaType + { + Schema = await GetSchemaFromBody(GetMediaType(request.ContentType), request.BodyString) + } + } + } + }; + } + + private async Task SetParametersFromRequestHeaders(OpenApiOperation operation, HeaderCollection headers) + { + if (headers is null || + !headers.Any()) + { + Logger.LogDebug(" Request has no headers"); + return; + } + + Logger.LogDebug(" Processing request headers..."); + foreach (var header in headers) + { + var lowerCaseHeaderName = header.Name.ToLowerInvariant(); + if (Http.StandardHeaders.Contains(lowerCaseHeaderName)) + { + Logger.LogDebug(" Skipping standard header {headerName}", header.Name); + continue; + } + + if (Http.AuthHeaders.Contains(lowerCaseHeaderName)) + { + Logger.LogDebug(" Skipping auth header {headerName}", header.Name); + continue; + } + + operation.Parameters.Add(new OpenApiParameter + { + Name = header.Name, + In = ParameterLocation.Header, + Required = false, + Schema = new OpenApiSchema { Type = "string" }, + Description = await GenerateParameterDescriptionAsync(header.Name, ParameterLocation.Header), + Extensions = new Dictionary + { + { "x-ms-summary", new OpenApiString(await GenerateParameterSummaryAsync(header.Name, ParameterLocation.Header)) } + } + }); + Logger.LogDebug(" Added header {headerName}", header.Name); + } + } + + private async Task SetParametersFromQueryString(OpenApiOperation operation, NameValueCollection queryParams) + { + if (queryParams.AllKeys is null || + queryParams.AllKeys.Length == 0) + { + Logger.LogDebug(" Request has no query string parameters"); + return; + } + + Logger.LogDebug(" Processing query string parameters..."); + var dictionary = (queryParams.AllKeys as string[]).ToDictionary(k => k, k => queryParams[k] as object); + + foreach (var parameter in dictionary) + { + operation.Parameters.Add(new OpenApiParameter + { + Name = parameter.Key, + In = ParameterLocation.Query, + Required = false, + Schema = new OpenApiSchema { Type = "string" }, + Description = await GenerateParameterDescriptionAsync(parameter.Key, ParameterLocation.Query), + Extensions = new Dictionary + { + { "x-ms-summary", new OpenApiString(await GenerateParameterSummaryAsync(parameter.Key, ParameterLocation.Query)) } + } + }); + Logger.LogDebug(" Added query string parameter {parameterKey}", parameter.Key); + } + } + + private async Task SetResponseFromSession(OpenApiOperation operation, Response response) + { + if (response is null) + { + Logger.LogDebug(" No response to process"); + return; + } + + Logger.LogDebug($" Processing response code {response.StatusCode} for operation {operation}..."); + + var responseCode = response.StatusCode.ToString(); + bool is2xx = response.StatusCode >= 200 && response.StatusCode < 300; + + // Find all 2xx codes already present, sorted numerically + var existing2xxCodes = operation.Responses.Keys + .Where(k => int.TryParse(k, out int code) && code >= 200 && code < 300) + .Select(k => int.Parse(k)) + .OrderBy(k => k) + .ToList(); + + // Determine if this is the lowest 2xx code + bool isLowest2xx = is2xx && (!existing2xxCodes.Any() || response.StatusCode < existing2xxCodes.First()); + + var openApiResponse = new OpenApiResponse + { + Description = isLowest2xx ? "default" : response.StatusDescription + }; + + if (response.HasBody) + { + Logger.LogDebug(" Response has body"); + var mediaType = GetMediaType(response.ContentType); + + if (isLowest2xx) + { + // Only the lowest 2xx response gets a schema + openApiResponse.Content.Add(mediaType, new OpenApiMediaType + { + Schema = await GetSchemaFromBody(mediaType, response.BodyString) + }); + } + else + { + // All other responses: no schema + openApiResponse.Content.Add(mediaType, new OpenApiMediaType()); + } + } + else + { + Logger.LogDebug(" Response doesn't have body"); + } + + // Check configuration before processing headers + if (!_configuration.IncludeResponseHeaders) + { + Logger.LogDebug(" Skipping response headers because IncludeResponseHeaders is set to false"); + } + else if (response.Headers is not null && response.Headers.Any()) + { + Logger.LogDebug(" Response has headers"); + + foreach (var header in response.Headers) + { + var lowerCaseHeaderName = header.Name.ToLowerInvariant(); + if (Http.StandardHeaders.Contains(lowerCaseHeaderName)) + { + Logger.LogDebug(" Skipping standard header {headerName}", header.Name); + continue; + } + + if (Http.AuthHeaders.Contains(lowerCaseHeaderName)) + { + Logger.LogDebug(" Skipping auth header {headerName}", header.Name); + continue; + } + + if (openApiResponse.Headers.ContainsKey(header.Name)) + { + Logger.LogDebug(" Header {headerName} already exists in response", header.Name); + continue; + } + + openApiResponse.Headers.Add(header.Name, new OpenApiHeader + { + Schema = new OpenApiSchema { Type = "string" } + }); + Logger.LogDebug(" Added header {headerName}", header.Name); + } + } + else + { + Logger.LogDebug(" Response doesn't have headers"); + } + + operation.Responses.Add(responseCode, openApiResponse); + } + + private static string GetMediaType(string? contentType) + { + if (string.IsNullOrEmpty(contentType)) + { + return contentType ?? ""; + } + + var mediaType = contentType.Split(';').First().Trim(); + return mediaType; + } + + private async Task GetSchemaFromBody(string? contentType, string body) + { + if (contentType is null) + { + Logger.LogDebug(" No content type to process"); + return null; + } + + if (contentType.StartsWith("application/json")) + { + Logger.LogDebug(" Processing JSON body..."); + return await GetSchemaFromJsonString(body); + } + + return null; + } + + private async Task AddOrMergePathItem(IList openApiDocs, OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + { + var serverUrl = requestUri.GetLeftPart(UriPartial.Authority); + var openApiDoc = openApiDocs.FirstOrDefault(d => d.Servers.Any(s => s.Url == serverUrl)); + + if (openApiDoc is null) + { + Logger.LogDebug(" Creating OpenAPI spec for {serverUrl}...", serverUrl); + + openApiDoc = new OpenApiDocument + { + Info = new OpenApiInfo + { + Version = "v1.0", + Title = await GetOpenApiTitleAsync($"{serverUrl} API"), + Description = await GetOpenApiDescriptionAsync($"{serverUrl} API"), + Contact = new OpenApiContact + { + Name = _configuration.Contact?.Name ?? "Your Name", + Url = new Uri(_configuration.Contact?.Url ?? "https://www.yourwebsite.com"), + Email = _configuration.Contact?.Email ?? "your.email@yourdomain.com" + } + }, + Servers = + [ + new OpenApiServer { Url = serverUrl } + ], + Paths = [], + Extensions = new Dictionary + { + { "x-ms-connector-metadata", new OpenApiArray + { + new OpenApiObject + { + ["propertyName"] = new OpenApiString("Website"), + ["propertyValue"] = new OpenApiString( + _configuration.ConnectorMetadata?.Website + ?? await GetConnectorMetadataWebsiteUrlAsync(serverUrl)) + }, + new OpenApiObject + { + ["propertyName"] = new OpenApiString("Privacy policy"), + ["propertyValue"] = new OpenApiString( + _configuration.ConnectorMetadata?.PrivacyPolicy + ?? await GetConnectorMetadataPrivacyPolicyUrlAsync(serverUrl)) + }, + new OpenApiObject + { + ["propertyName"] = new OpenApiString("Categories"), + ["propertyValue"] = new OpenApiString( + _configuration.ConnectorMetadata?.Categories + ?? await GetConnectorMetadataCategoriesAsync(serverUrl, "Data")) + } + } + } + } + }; + openApiDocs.Add(openApiDoc); + } + else + { + Logger.LogDebug(" Found OpenAPI spec for {serverUrl}...", serverUrl); + } + + if (!openApiDoc.Paths.TryGetValue(parametrizedPath, out OpenApiPathItem? value)) + { + Logger.LogDebug(" Adding path {parametrizedPath} to OpenAPI spec...", parametrizedPath); + value = pathItem; + openApiDoc.Paths.Add(parametrizedPath, value); + // since we've just added the path, we're done + return; + } + + Logger.LogDebug(" Merging path {parametrizedPath} into OpenAPI spec...", parametrizedPath); + var operation = pathItem.Operations.First(); + AddOrMergeOperation(value, operation.Key, operation.Value); + } + + private void AddOrMergeOperation(OpenApiPathItem pathItem, OperationType operationType, OpenApiOperation apiOperation) + { + if (!pathItem.Operations.TryGetValue(operationType, out OpenApiOperation? value)) + { + Logger.LogDebug(" Adding operation {operationType} to path...", operationType); + + pathItem.AddOperation(operationType, apiOperation); + // since we've just added the operation, we're done + return; + } + + Logger.LogDebug(" Merging operation {operationType} into path...", operationType); + + var operation = value; + + AddOrMergeParameters(operation, apiOperation.Parameters); + AddOrMergeRequestBody(operation, apiOperation.RequestBody); + AddOrMergeResponse(operation, apiOperation.Responses); + } + + private void AddOrMergeParameters(OpenApiOperation operation, IList parameters) + { + if (parameters is null || !parameters.Any()) + { + Logger.LogDebug(" No parameters to process"); + return; + } + + Logger.LogDebug(" Processing parameters for operation..."); + + foreach (var parameter in parameters) + { + var paramFromOperation = operation.Parameters.FirstOrDefault(p => p.Name == parameter.Name && p.In == parameter.In); + if (paramFromOperation is null) + { + Logger.LogDebug(" Adding parameter {parameterName} to operation...", parameter.Name); + + operation.Parameters.Add(parameter); + continue; + } + + Logger.LogDebug(" Merging parameter {parameterName}...", parameter.Name); + MergeSchema(parameter?.Schema, paramFromOperation?.Schema); + } + } + + private void MergeSchema(OpenApiSchema? source, OpenApiSchema? target) + { + if (source is null || target is null) + { + Logger.LogDebug(" Source or target is null. Skipping..."); + return; + } + + if (source.Type != "object" || target.Type != "object") + { + Logger.LogDebug(" Source or target schema is not an object. Skipping..."); + return; + } + + if (source.Properties is null || !source.Properties.Any()) + { + Logger.LogDebug(" Source has no properties. Skipping..."); + return; + } + + if (target.Properties is null || !target.Properties.Any()) + { + Logger.LogDebug(" Target has no properties. Skipping..."); + return; + } + + foreach (var property in source.Properties) + { + var propertyFromTarget = target.Properties.FirstOrDefault(p => p.Key == property.Key); + if (propertyFromTarget.Value is null) + { + Logger.LogDebug(" Adding property {propertyKey} to schema...", property.Key); + target.Properties.Add(property); + continue; + } + + if (property.Value.Type != "object") + { + Logger.LogDebug(" Property already found but is not an object. Skipping..."); + continue; + } + + Logger.LogDebug(" Merging property {propertyKey}...", property.Key); + MergeSchema(property.Value, propertyFromTarget.Value); + } + } + + private void AddOrMergeRequestBody(OpenApiOperation operation, OpenApiRequestBody requestBody) + { + if (requestBody is null || !requestBody.Content.Any()) + { + Logger.LogDebug(" No request body to process"); + return; + } + + var requestBodyType = requestBody.Content.FirstOrDefault().Key; + operation.RequestBody.Content.TryGetValue(requestBodyType, out OpenApiMediaType? bodyFromOperation); + + if (bodyFromOperation is null) + { + Logger.LogDebug(" Adding request body to operation..."); + + operation.RequestBody.Content.Add(requestBody.Content.FirstOrDefault()); + // since we've just added the request body, we're done + return; + } + + Logger.LogDebug(" Merging request body into operation..."); + MergeSchema(bodyFromOperation.Schema, requestBody.Content.FirstOrDefault().Value.Schema); + } + + private void AddOrMergeResponse(OpenApiOperation operation, OpenApiResponses apiResponses) + { + if (apiResponses is null) + { + Logger.LogDebug(" No response to process"); + return; + } + + var apiResponseInfo = apiResponses.FirstOrDefault(); + var apiResponseStatusCode = apiResponseInfo.Key; + var apiResponse = apiResponseInfo.Value; + operation.Responses.TryGetValue(apiResponseStatusCode, out OpenApiResponse? responseFromOperation); + + if (responseFromOperation is null) + { + Logger.LogDebug(" Adding response {apiResponseStatusCode} to operation...", apiResponseStatusCode); + + operation.Responses.Add(apiResponseStatusCode, apiResponse); + // since we've just added the response, we're done + return; + } + + if (!apiResponse.Content.Any()) + { + Logger.LogDebug(" No response content to process"); + return; + } + + var apiResponseContentType = apiResponse.Content.First().Key; + responseFromOperation.Content.TryGetValue(apiResponseContentType, out OpenApiMediaType? contentFromOperation); + + if (contentFromOperation is null) + { + Logger.LogDebug(" Adding response {apiResponseContentType} to {apiResponseStatusCode} to response...", apiResponseContentType, apiResponseStatusCode); + + responseFromOperation.Content.Add(apiResponse.Content.First()); + // since we've just added the content, we're done + return; + } + + Logger.LogDebug(" Merging response {apiResponseStatusCode}/{apiResponseContentType} into operation...", apiResponseStatusCode, apiResponseContentType); + MergeSchema(contentFromOperation.Schema, apiResponse.Content.First().Value.Schema); + } + + private static string GetFileNameFromServerUrl(string serverUrl, SpecFormat format) + { + var uri = new Uri(serverUrl); + var ext = format switch + { + SpecFormat.Json => "json", + SpecFormat.Yaml => "yaml", + _ => "json" + }; + var fileName = $"{uri.Host}-{DateTime.Now:yyyyMMddHHmmss}.{ext}"; + return fileName; + } + + private async Task GetSchemaFromJsonString(string jsonString) + { + try + { + using var doc = JsonDocument.Parse(jsonString); + JsonElement root = doc.RootElement; + var schema = await GetSchemaFromJsonElement(root); + return schema; + } + catch + { + return new OpenApiSchema + { + Type = "object" + }; + } + } + + private async Task GetResponsePropertyTitleAsync(string propertyName) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable title for the property. + The title must: + - Be in Title Case (capitalize the first letter of each word). + - Be 2-5 words long. + - Not include underscores, dashes, or technical jargon. + - Not repeat the property name verbatim if it contains underscores or is not human-friendly. + - Be clear, descriptive, and suitable for use as a 'title' in OpenAPI schema properties. + + Examples: + Property Name: tenant_id + Title: Tenant ID + + Property Name: event_type + Title: Event Type + + Property Name: created_at + Title: Created At + + Property Name: user_email_address + Title: User Email Address + + Now, generate a title for this property: + Property Name: {propertyName} + Title: + "; + + ILanguageModelCompletionResponse? response = null; + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.3 }); + } + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetResponsePropertyTitleFallback(propertyName); + } + + // Fallback if LLM fails + private static string GetResponsePropertyTitleFallback(string propertyName) + { + return string.Join(" ", propertyName + .Replace("_", " ") + .Replace("-", " ") + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(word => char.ToUpperInvariant(word[0]) + word.Substring(1))); + } + + private async Task GetResponsePropertyDescriptionAsync(string propertyName) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable description for the property. + The description must: + - Be a full, descriptive sentence and end in punctuation. + - Be written in English. + - Be free of grammatical and spelling errors. + - Clearly explain the purpose or meaning of the property. + - Not repeat the property name verbatim if it contains underscores or is not human-friendly. + - Be suitable for use as a 'description' in OpenAPI schema properties. + - Only return the description, without any additional text or explanation. + + Examples: + Property Name: tenant_id + Description: The ID of the tenant this notification belongs to. + + Property Name: event_type + Description: The type of the event. + + Property Name: created_at + Description: The timestamp of when the event was generated. + + Property Name: user_email_address + Description: The email address of the user who triggered the event. + + Now, generate a description for this property: + Property Name: {propertyName} + Description: + "; + + ILanguageModelCompletionResponse? response = null; + if (await Context.LanguageModelClient.IsEnabledAsync()) + { + response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.3 }); + } + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetResponsePropertyDescriptionFallback(propertyName); + } + + // Fallback if LLM fails + private static string GetResponsePropertyDescriptionFallback(string propertyName) + { + // Simple fallback: "The value of {Property Name}." + var title = string.Join(" ", propertyName + .Replace("_", " ") + .Replace("-", " ") + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(word => char.ToUpperInvariant(word[0]) + word.Substring(1))); + return $"The value of {title}."; + } + + private async Task GetSchemaFromJsonElement(JsonElement jsonElement, string? propertyName = null) + { + // Log the start of processing this element + Logger.LogDebug("Processing JSON element{0}{1}", + propertyName != null ? $" for property '{propertyName}'" : string.Empty, + $", ValueKind: {jsonElement.ValueKind}"); + + var schema = new OpenApiSchema(); + switch (jsonElement.ValueKind) + { + case JsonValueKind.String: + schema.Type = "string"; + schema.Title = await GetResponsePropertyTitleAsync(propertyName ?? string.Empty); + Logger.LogDebug(" Set type 'string' for property '{propertyName}'", propertyName); + break; + case JsonValueKind.Number: + schema.Type = "number"; + schema.Title = await GetResponsePropertyTitleAsync(propertyName ?? string.Empty); + Logger.LogDebug(" Set type 'number' for property '{propertyName}'", propertyName); + break; + case JsonValueKind.True: + case JsonValueKind.False: + schema.Type = "boolean"; + schema.Title = await GetResponsePropertyTitleAsync(propertyName ?? string.Empty); + Logger.LogDebug(" Set type 'boolean' for property '{propertyName}'", propertyName); + break; + case JsonValueKind.Object: + schema.Type = "object"; + schema.Properties = new Dictionary(); + Logger.LogDebug(" Processing object properties for '{propertyName}'", propertyName); + foreach (var prop in jsonElement.EnumerateObject()) + { + schema.Properties[prop.Name] = await GetSchemaFromJsonElement(prop.Value, prop.Name); + } + break; + case JsonValueKind.Array: + schema.Type = "array"; + Logger.LogDebug(" Processing array items for '{propertyName}'", propertyName); + schema.Items = await GetSchemaFromJsonElement(jsonElement.EnumerateArray().FirstOrDefault(), propertyName); + break; + default: + schema.Type = "object"; + Logger.LogDebug(" Set default type 'object' for property '{propertyName}'", propertyName); + break; + } + schema.Description = await GetResponsePropertyDescriptionAsync(propertyName ?? string.Empty); + Logger.LogDebug(" Set description for property '{propertyName}': {description}", propertyName, schema.Description); + return schema; + } +} diff --git a/schemas/v0.27.0/powerplatformspecgeneratorplugin.schema.json b/schemas/v0.27.0/powerplatformspecgeneratorplugin.schema.json new file mode 100644 index 00000000..f79e5f2a --- /dev/null +++ b/schemas/v0.27.0/powerplatformspecgeneratorplugin.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy PowerPlatformSpecGeneratorPlugin config schema", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "includeOptionsRequests": { + "type": "boolean", + "description": "Determines whether to include OPTIONS requests in the generated OpenAPI spec. Default: false." + }, + "specFormat": { + "type": "string", + "enum": [ + "Json", + "Yaml" + ], + "description": "Specifies the format of the generated OpenAPI spec. Allowed values: 'Json' or 'Yaml'. Default: 'Json'." + }, + "includeRequestHeaders": { + "type": "boolean", + "description": "Determines whether to include request headers in the generated OpenAPI spec. Default: false." + }, + "contact": { + "type": "object", + "description": "Contact information for the API.", + "properties": { + "name": { + "type": "string", + "description": "The name of the contact person or organization." + }, + "url": { + "type": "string", + "format": "uri", + "description": "The URL pointing to the contact information." + }, + "email": { + "type": "string", + "format": "email", + "description": "The email address of the contact person or organization." + } + }, + "additionalProperties": false + }, + "connectorMetadata": { + "type": "object", + "description": "Optional metadata for the connector.", + "properties": { + "website": { + "type": "string", + "format": "uri", + "description": "The corporate website URL for the API." + }, + "privacyPolicy": { + "type": "string", + "format": "uri", + "description": "The privacy policy URL for the API." + }, + "categories": { + "type": "string", + "description": "Comma-separated categories for the API." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} \ No newline at end of file From 1e2129523285a8f48a8847c989b1fcddc44293a8 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Fri, 20 Jun 2025 13:08:58 -0400 Subject: [PATCH 02/24] Add PowerPlatformSpecGeneratorPlugin for OpenAPI spec generation from recorded requests - Implemented PowerPlatformSpecGeneratorPlugin to create OpenAPI specifications based on intercepted HTTP requests. - Configured plugin to handle various request types, including OPTIONS, and to serialize specs in JSON or YAML formats. - Integrated language model for generating operation IDs, summaries, and descriptions based on request metadata. - Added support for parameterization of request paths and dynamic generation of OpenAPI schemas from request bodies. - Included logging for better traceability and debugging during the spec generation process. - Established a report structure to store generated spec details for further use by other plugins. --- .../Extensions}/PowerPlatformSpecGeneratorPlugin.cs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {dev-proxy-plugins/RequestLogs => DevProxy.Plugins/Extensions}/PowerPlatformSpecGeneratorPlugin.cs (100%) diff --git a/dev-proxy-plugins/RequestLogs/PowerPlatformSpecGeneratorPlugin.cs b/DevProxy.Plugins/Extensions/PowerPlatformSpecGeneratorPlugin.cs similarity index 100% rename from dev-proxy-plugins/RequestLogs/PowerPlatformSpecGeneratorPlugin.cs rename to DevProxy.Plugins/Extensions/PowerPlatformSpecGeneratorPlugin.cs From 3166f086aafd79c06c1afe31c3667d438ef9d72f Mon Sep 17 00:00:00 2001 From: ricwilson Date: Mon, 23 Jun 2025 11:34:00 -0400 Subject: [PATCH 03/24] Refactor OpenApiSpecGeneratorPlugin and add PowerPlatformOpenApiSpecGeneratorPlugin with enhanced path item processing --- .../Generation/OpenApiSpecGeneratorPlugin.cs | 37 ++- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 307 ++++++++++++++++++ 2 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 55e33bae..e08bb241 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -57,7 +57,7 @@ public sealed class OpenApiSpecGeneratorPluginConfiguration public SpecVersion SpecVersion { get; set; } = SpecVersion.v3_0; } -public sealed class OpenApiSpecGeneratorPlugin( +public class OpenApiSpecGeneratorPlugin( ILogger logger, ISet urlsToWatch, ILanguageModelClient languageModelClient, @@ -89,12 +89,13 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e) var openApiDocs = new List(); + foreach (var request in e.RequestLogs) { if (request.MessageType != MessageType.InterceptedResponse || - request.Context is null || - request.Context.Session is null || - !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)) + request.Context is null || + request.Context.Session is null || + !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)) { continue; } @@ -124,7 +125,8 @@ request.Context.Session is null || request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), parametrizedPath ); - AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); + var processedPathItem = ProcessPathItem(pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); + AddOrMergePathItem(openApiDocs, processedPathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); } catch (Exception ex) { @@ -132,7 +134,7 @@ request.Context.Session is null || } } - Logger.LogDebug("Serializing OpenAPI docs..."); + // Serialize and write OpenAPI docs var generatedOpenApiSpecs = new Dictionary(); foreach (var openApiDoc in openApiDocs) { @@ -163,11 +165,11 @@ request.Context.Session is null || StoreReport(new OpenApiSpecGeneratorPluginReport( generatedOpenApiSpecs - .Select(kvp => new OpenApiSpecGeneratorPluginReportItem - { - ServerUrl = kvp.Key, - FileName = kvp.Value - })), e); + .Select(kvp => new OpenApiSpecGeneratorPluginReportItem + { + ServerUrl = kvp.Key, + FileName = kvp.Value + })), e); // store the generated OpenAPI specs in the global data // for use by other plugins @@ -176,6 +178,19 @@ request.Context.Session is null || Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); } + /// + /// Allows derived plugins to post-process the OpenApiPathItem before it is added/merged into the document. + /// + /// The OpenApiPathItem to process. + /// The request URI. + /// The parametrized path string. + /// The processed OpenApiPathItem. + protected virtual OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + { + // By default, return the path item unchanged. + return pathItem; + } + private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) { ILanguageModelCompletionResponse? id = null; diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs new file mode 100644 index 00000000..0115059c --- /dev/null +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -0,0 +1,307 @@ +using DevProxy.Abstractions.LanguageModel; +using DevProxy.Abstractions.Proxy; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Any; +using System.Globalization; + + +namespace DevProxy.Plugins.Generation; + +public class ContactConfig +{ + public string Name { get; set; } = "Your Name"; + public string Url { get; set; } = "https://www.yourwebsite.com"; + public string Email { get; set; } = "your.email@yourdomain.com"; +} + +public class ConnectorMetadataConfig +{ + public string? Website { get; set; } + public string? PrivacyPolicy { get; set; } + public string? Categories { get; set; } +} + +public class PowerPlatformOpenApiSpecGeneratorPlugin : OpenApiSpecGeneratorPlugin +{ + private readonly ILanguageModelClient _languageModelClient; + +#pragma warning disable IDE0290 // Use primary constructor + public PowerPlatformOpenApiSpecGeneratorPlugin( +#pragma warning restore IDE0290 // Use primary constructor + ILogger logger, + ISet urlsToWatch, + ILanguageModelClient languageModelClient, + IProxyConfiguration proxyConfiguration, + IConfigurationSection pluginConfigurationSection + ) : base(logger, urlsToWatch, languageModelClient, proxyConfiguration, pluginConfigurationSection) + { + _languageModelClient = languageModelClient; + } + + + public override string Name => nameof(PowerPlatformOpenApiSpecGeneratorPlugin); + + protected override OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + { + ArgumentNullException.ThrowIfNull(pathItem); + ArgumentNullException.ThrowIfNull(requestUri); + + // // Generate and add connector metadata + // var serverUrl = requestUri.GetLeftPart(UriPartial.Authority); + // // Synchronously wait for the async method (not recommended for production, but matches signature) + // var connectorMetadata = GenerateConnectorMetadataAsync(serverUrl).GetAwaiter().GetResult(); + // pathItem.Extensions["x-ms-connector-metadata"] = connectorMetadata; + + return pathItem; + } + + private async Task GetResponsePropertyTitleAsync(string propertyName) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable title for the property. + The title must: + - Be in Title Case (capitalize the first letter of each word). + - Be 2-5 words long. + - Not include underscores, dashes, or technical jargon. + - Not repeat the property name verbatim if it contains underscores or is not human-friendly. + - Be clear, descriptive, and suitable for use as a 'title' in OpenAPI schema properties. + + Examples: + Property Name: tenant_id + Title: Tenant ID + + Property Name: event_type + Title: Event Type + + Property Name: created_at + Title: Created At + + Property Name: user_email_address + Title: User Email Address + + Now, generate a title for this property: + Property Name: {propertyName} + Title: + "; + + ILanguageModelCompletionResponse? response = null; + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.3 }); + } + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetResponsePropertyTitleFallback(propertyName); + } + + // Fallback if LLM fails + private static string GetResponsePropertyTitleFallback(string propertyName) + { + // Replace underscores and dashes with spaces, then ensure all lowercase before capitalizing + var formattedPropertyName = propertyName + .Replace("_", " ", StringComparison.InvariantCulture) + .Replace("-", " ", StringComparison.InvariantCulture) + .ToLowerInvariant(); + + // Use TextInfo.ToTitleCase with InvariantCulture to capitalize each word + var textInfo = CultureInfo.InvariantCulture.TextInfo; + var title = textInfo.ToTitleCase(formattedPropertyName); + + return title; + } + + private async Task GetResponsePropertyDescriptionAsync(string propertyName) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable description for the property. + The description must: + - Be a full, descriptive sentence and end in punctuation. + - Be written in English. + - Be free of grammatical and spelling errors. + - Clearly explain the purpose or meaning of the property. + - Not repeat the property name verbatim if it contains underscores or is not human-friendly. + - Be suitable for use as a 'description' in OpenAPI schema properties. + - Only return the description, without any additional text or explanation. + + Examples: + Property Name: tenant_id + Description: The ID of the tenant this notification belongs to. + + Property Name: event_type + Description: The type of the event. + + Property Name: created_at + Description: The timestamp of when the event was generated. + + Property Name: user_email_address + Description: The email address of the user who triggered the event. + + Now, generate a description for this property: + Property Name: {propertyName} + Description: + "; + + ILanguageModelCompletionResponse? response = null; + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.3 }); + } + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetResponsePropertyDescriptionFallback(propertyName); + } + + // Fallback if LLM fails + private static string GetResponsePropertyDescriptionFallback(string propertyName) + { + // Convert underscores and dashes to spaces, then ensure all lowercase before capitalizing + var formattedPropertyName = propertyName + .Replace("_", " ", StringComparison.InvariantCulture) + .Replace("-", " ", StringComparison.InvariantCulture) + .ToLowerInvariant(); + + // Use TextInfo.ToTitleCase with InvariantCulture to capitalize each word + var textInfo = CultureInfo.InvariantCulture.TextInfo; + var description = textInfo.ToTitleCase(formattedPropertyName); + + // Construct the final description + return $"The value of {description}."; + } + + + + // private async Task GenerateConnectorMetadataAsync(string serverUrl) + // { + // var website = await GetConnectorMetadataWebsiteUrlAsync(serverUrl); + // var privacyPolicy = await GetConnectorMetadataPrivacyPolicyUrlAsync(serverUrl); + // var categories = await GetConnectorMetadataCategoriesAsync(serverUrl, "Data"); + + // var metadataArray = new OpenApiArray + // { + // new OpenApiObject + // { + // ["propertyName"] = new OpenApiString("Website"), + // ["propertyValue"] = new OpenApiString(website) + // }, + // new OpenApiObject + // { + // ["propertyName"] = new OpenApiString("Privacy policy"), + // ["propertyValue"] = new OpenApiString(privacyPolicy) + // }, + // new OpenApiObject + // { + // ["propertyName"] = new OpenApiString("Categories"), + // ["propertyValue"] = new OpenApiString(categories) + // } + // }; + // return metadataArray; + // } + + // private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl) + // { + // var prompt = $@" + // You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the corporate website URL for the API. + // If the corporate website URL cannot be determined, respond with the default URL provided. + + // API Metadata: + // - Default URL: {defaultUrl} + + // Rules you must follow: + // - Do not output any explanations or additional text. + // - The URL must be a valid, publicly accessible website. + // - The URL must not contain placeholders or invalid characters. + // - If no corporate website URL can be determined, return the default URL. + + // Example: + // Default URL: https://example.com + // Response: https://example.com + + // Now, determine the corporate website URL for this API."; + + // ILanguageModelCompletionResponse? response = null; + + // if (await _languageModelClient.IsEnabledAsync()) + // { + // response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + // } + + // // Fallback to the default URL if the language model fails or returns no response + // return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; + // } + + // private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defaultUrl) + // { + // var prompt = $@" + // You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the privacy policy URL for the corporate website or API. + // If the privacy policy URL cannot be determined, respond with the default URL provided. + + // API Metadata: + // - Default URL: {defaultUrl} + + // Rules you must follow: + // - Do not output any explanations or additional text. + // - The URL must be a valid, publicly accessible website. + // - The URL must not contain placeholders or invalid characters. + // - If no privacy policy URL can be determined, return the default URL. + + // Example: + // Response: https://example.com/privacy + + // Now, determine the privacy policy URL for this API."; + + // ILanguageModelCompletionResponse? response = null; + + // if (await _languageModelClient.IsEnabledAsync()) + // { + // response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + // } + + // // Fallback to the default URL if the language model fails or returns no response + // return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; + // } + + // private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, string defaultCategories) + // { + // var allowedCategories = @"""AI"", ""Business Management"", ""Business Intelligence"", ""Collaboration"", ""Commerce"", ""Communication"", + // ""Content and Files"", ""Finance"", ""Data"", ""Human Resources"", ""Internet of Things"", ""IT Operations"", + // ""Lifestyle and Entertainment"", ""Marketing"", ""Productivity"", ""Sales and CRM"", ""Security"", + // ""Social Media"", ""Website"""; + + // var prompt = $@" + // You're an expert in OpenAPI and API documentation. Based on the following API metadata and the server URL, determine the most appropriate categories for the API from the allowed list of categories. + // If you cannot determine appropriate categories, respond with 'None'. + + // API Metadata: + // - Server URL: {serverUrl} + // - Allowed Categories: {allowedCategories} + + // Rules you must follow: + // - Do not output any explanations or additional text. + // - The categories must be from the allowed list. + // - The categories must be relevant to the API's functionality and purpose. + // - The categories should be in a comma-separated format. + // - If you cannot determine appropriate categories, respond with 'None'. + + // Example: + // Allowed Categories: AI, Data + // Response: Data + + // Now, determine the categories for this API."; + + // ILanguageModelCompletionResponse? response = null; + + // if (await _languageModelClient.IsEnabledAsync()) + // { + // response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + // } + + // // If the response is 'None' or empty, return the default categories + // return !string.IsNullOrWhiteSpace(response?.Response) && response.Response.Trim() != "None" + // ? response.Response + // : defaultCategories; + // } + + +} From fffe4478441a82a0310c2bb36a791a7188e7eab8 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Mon, 23 Jun 2025 23:18:49 -0400 Subject: [PATCH 04/24] feat: Enhance OpenAPI Spec Generator Plugin with post-processing capabilities - Refactored OpenApiSpecGeneratorPluginConfiguration to be non-sealed for extensibility. - Added ProcessOpenApiDocument method to allow derived plugins to modify OpenAPI documents before serialization. - Implemented PowerPlatformOpenApiSpecGeneratorPlugin to utilize new post-processing features, including setting contact info and generating metadata. - Introduced methods for generating operation IDs, summaries, and descriptions using language model completions. - Added JSON schema for PowerPlatformOpenApiSpecGeneratorPlugin configuration, including new properties for contact and connector metadata. --- .../PowerPlatformSpecGeneratorPlugin.cs | 1270 ----------------- .../Generation/OpenApiSpecGeneratorPlugin.cs | 14 +- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 656 +++++++-- ...ormopenapispecgeneratorplugin.schema.json} | 4 +- 4 files changed, 536 insertions(+), 1408 deletions(-) delete mode 100644 DevProxy.Plugins/Extensions/PowerPlatformSpecGeneratorPlugin.cs rename schemas/{v0.27.0/powerplatformspecgeneratorplugin.schema.json => v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json} (94%) diff --git a/DevProxy.Plugins/Extensions/PowerPlatformSpecGeneratorPlugin.cs b/DevProxy.Plugins/Extensions/PowerPlatformSpecGeneratorPlugin.cs deleted file mode 100644 index b2c5c9ee..00000000 --- a/DevProxy.Plugins/Extensions/PowerPlatformSpecGeneratorPlugin.cs +++ /dev/null @@ -1,1270 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Microsoft.Extensions.Configuration; -using DevProxy.Abstractions; -using Titanium.Web.Proxy.EventArguments; -using Microsoft.OpenApi.Models; -using Microsoft.OpenApi.Extensions; -using System.Text.Json; -using Microsoft.OpenApi.Interfaces; -using Microsoft.OpenApi.Writers; -using Microsoft.OpenApi; -using Titanium.Web.Proxy.Http; -using System.Web; -using System.Collections.Specialized; -using Microsoft.Extensions.Logging; -using DevProxy.Abstractions.LanguageModel; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using Microsoft.OpenApi.Any; - -namespace DevProxy.Plugins.RequestLogs; - -public class PowerPlatformSpecGeneratorPluginReportItem -{ - public required string ServerUrl { get; init; } - public required string FileName { get; init; } -} - -public class PowerPlatformSpecGeneratorPluginReport : List -{ - public PowerPlatformSpecGeneratorPluginReport() : base() { } - - public PowerPlatformSpecGeneratorPluginReport(IEnumerable collection) : base(collection) { } -} - -internal class PowerPlatformSpecGeneratorPluginConfiguration -{ - public bool IncludeOptionsRequests { get; set; } = false; - - public SpecFormat SpecFormat { get; set; } = SpecFormat.Json; - - public bool IncludeResponseHeaders { get; set; } = false; - - public ContactConfig? Contact { get; set; } - - public ConnectorMetadataConfig? ConnectorMetadata { get; set; } -} - -public class ContactConfig -{ - public string Name { get; set; } = "Your Name"; - public string Url { get; set; } = "https://www.yourwebsite.com"; - public string Email { get; set; } = "your.email@yourdomain.com"; -} - -public class ConnectorMetadataConfig -{ - public string? Website { get; set; } - public string? PrivacyPolicy { get; set; } - public string? Categories { get; set; } -} - -public class PowerPlatformSpecGeneratorPlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReportingPlugin(pluginEvents, context, logger, urlsToWatch, configSection) -{ - public override string Name => nameof(PowerPlatformSpecGeneratorPlugin); - private readonly PowerPlatformSpecGeneratorPluginConfiguration _configuration = new(); - public static readonly string GeneratedPowerPlatformSpecsKey = "GeneratedPowerPlatformSpecs"; - - public override async Task RegisterAsync() - { - await base.RegisterAsync(); - - ConfigSection?.Bind(_configuration); - - PluginEvents.AfterRecordingStop += AfterRecordingStopAsync; - } - - private async Task AfterRecordingStopAsync(object? sender, RecordingArgs e) - { - Logger.LogInformation("Creating Power Platform spec from recorded requests..."); - - if (!e.RequestLogs.Any()) - { - Logger.LogDebug("No requests to process"); - return; - } - - var openApiDocs = new List(); - - foreach (var request in e.RequestLogs) - { - if (request.MessageType != MessageType.InterceptedResponse || - request.Context is null || - request.Context.Session is null || - !ProxyUtils.MatchesUrlToWatch(UrlsToWatch, request.Context.Session.HttpClient.Request.RequestUri.AbsoluteUri)) - { - continue; - } - - if (!_configuration.IncludeOptionsRequests && - string.Equals(request.Context.Session.HttpClient.Request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("Skipping OPTIONS request {url}...", request.Context.Session.HttpClient.Request.RequestUri); - continue; - } - - var methodAndUrlString = request.Message.First(); - Logger.LogDebug("Processing request {methodAndUrlString}...", methodAndUrlString); - - try - { - var pathItem = await GetOpenApiPathItem(request.Context.Session); - var parametrizedPath = ParametrizePath(pathItem, request.Context.Session.HttpClient.Request.RequestUri); - var operationInfo = pathItem.Operations.First(); - operationInfo.Value.OperationId = await GetOperationIdAsync( - operationInfo.Key.ToString(), - request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), - parametrizedPath - ); - operationInfo.Value.Description = await GetOperationDescriptionAsync( - operationInfo.Key.ToString(), - request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), - parametrizedPath - ); - operationInfo.Value.Summary = await GetOperationSummaryAsync( - operationInfo.Key.ToString(), - request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), - parametrizedPath - ); - await AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error processing request {methodAndUrl}", methodAndUrlString); - } - } - - Logger.LogDebug("Serializing OpenAPI docs..."); - var generatedOpenApiSpecs = new Dictionary(); - foreach (var openApiDoc in openApiDocs) - { - var server = openApiDoc.Servers.First(); - var fileName = GetFileNameFromServerUrl(server.Url, _configuration.SpecFormat); - - var openApiSpecVersion = OpenApiSpecVersion.OpenApi2_0; - - var docString = _configuration.SpecFormat switch - { - SpecFormat.Json => openApiDoc.SerializeAsJson(openApiSpecVersion), - SpecFormat.Yaml => openApiDoc.SerializeAsYaml(openApiSpecVersion), - _ => openApiDoc.SerializeAsJson(openApiSpecVersion) - }; - - Logger.LogDebug(" Writing OpenAPI spec to {fileName}...", fileName); - File.WriteAllText(fileName, docString); - - generatedOpenApiSpecs.Add(server.Url, fileName); - - Logger.LogInformation("Created Power Platform spec file {fileName}", fileName); - } - - StoreReport(new PowerPlatformSpecGeneratorPluginReport( - generatedOpenApiSpecs - .Select(kvp => new PowerPlatformSpecGeneratorPluginReportItem - { - ServerUrl = kvp.Key, - FileName = kvp.Value - })), e); - - // store the generated OpenAPI specs in the global data - // for use by other plugins - e.GlobalData[GeneratedPowerPlatformSpecsKey] = generatedOpenApiSpecs; - } - - /** - * Replaces segments in the request URI, that match predefined patters, - * with parameters and adds them to the OpenAPI PathItem. - * @param pathItem The OpenAPI PathItem to parametrize. - * @param requestUri The request URI. - * @returns The parametrized server-relative URL - */ - private static string ParametrizePath(OpenApiPathItem pathItem, Uri requestUri) - { - var segments = requestUri.Segments; - var previousSegment = "item"; - - for (var i = 0; i < segments.Length; i++) - { - var segment = requestUri.Segments[i].Trim('/'); - if (string.IsNullOrEmpty(segment)) - { - continue; - } - - if (IsParametrizable(segment)) - { - var parameterName = $"{previousSegment}-id"; - segments[i] = $"{{{parameterName}}}{(requestUri.Segments[i].EndsWith('/') ? "/" : "")}"; - - pathItem.Parameters.Add(new OpenApiParameter - { - Name = parameterName, - In = ParameterLocation.Path, - Required = true, - Schema = new OpenApiSchema { Type = "string" } - }); - } - else - { - previousSegment = segment; - } - } - - return string.Join(string.Empty, segments); - } - - private static bool IsParametrizable(string segment) - { - return Guid.TryParse(segment.Trim('/'), out _) || - int.TryParse(segment.Trim('/'), out _); - } - - private static string GetLastNonTokenSegment(string[] segments) - { - for (var i = segments.Length - 1; i >= 0; i--) - { - var segment = segments[i].Trim('/'); - if (string.IsNullOrEmpty(segment)) - { - continue; - } - - if (!IsParametrizable(segment)) - { - return segment; - } - } - - return "item"; - } - - private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) - { - var prompt = @"**Prompt:** - Generate an operation ID for an OpenAPI specification based on the HTTP method and URL provided. Follow these rules: - - The operation ID should be in camelCase format. - - Start with a verb that matches the HTTP method (e.g., `get`, `create`, `update`, `delete`). - - Use descriptive words from the URL path. - - Replace path parameters (e.g., `{userId}`) with relevant nouns in singular form (e.g., `User`). - - Do not provide explanations or any other text; respond only with the operation ID. - - Example: - **Request:** `GET https://api.contoso.com/books/{books-id}` - getBook - - Example: - **Request:** `GET https://api.contoso.com/books/{books-id}/authors` - getBookAuthors - - Example: - **Request:** `GET https://api.contoso.com/books/{books-id}/authors/{authors-id}` - getBookAuthor - - Example: - **Request:** `POST https://api.contoso.com/books/{books-id}/authors` - addBookAuthor - - Now, generate the operation ID for the following: - **Request:** `{request}`".Replace("{request}", $"{method.ToUpper()} {serverUrl}{parametrizedPath}"); - ILanguageModelCompletionResponse? id = null; - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - id = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 1 }); - } - return id?.Response?.Trim() ?? $"{method}{parametrizedPath.Replace('/', '.')}"; - } - - private async Task GetOperationSummaryAsync(string method, string serverUrl, string parametrizedPath) - { - var prompt = $@"You're an expert in OpenAPI. - You help developers build great OpenAPI specs for use with LLMs. - For the specified request, generate a concise, one-sentence summary that adheres to the following rules: - - Must exist and be written in English. - - Must be a phrase and cannot not end with punctuation. - - Must be free of grammatical and spelling errors. - - Must be 80 characters or less. - - Must contain only alphanumeric characters or parentheses. - - Must not include the words API, Connector, or any other Power Platform product names (for example, Power Apps). - - Respond with just the summary. - - For example: - - For a request such as `GET https://api.contoso.com/books/{{books-id}}`, return `Get a book by ID` - - For a request such as `POST https://api.contoso.com/books`, return `Create a new book` - - Request: {method.ToUpper()} {serverUrl}{parametrizedPath}"; - ILanguageModelCompletionResponse? description = null; - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - description = await Context.LanguageModelClient.GenerateCompletionAsync(prompt); - } - return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; - } - - private async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath) - { - var prompt = $@"You're an expert in OpenAPI. - You help developers build great OpenAPI specs for use with LLMs. - For the specified request, generate a one-sentence description that ends in punctuation. - Respond with just the description. - For example, for a request such as `GET https://api.contoso.com/books/{{books-id}}` - // you return `Get a book by ID`. Request: {method.ToUpper()} {serverUrl}{parametrizedPath}"; - ILanguageModelCompletionResponse? description = null; - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - description = await Context.LanguageModelClient.GenerateCompletionAsync(prompt); - } - return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; - } - - private async Task GenerateParameterDescriptionAsync(string parameterName, ParameterLocation location) - { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. - The description must adhere to the following rules: - - Must exist and be written in English. - - Must be a full, descriptive sentence, and end in punctuation. - - Must be free of grammatical and spelling errors. - - Must describe the purpose of the parameter and its role in the request. - - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). - - Parameter Metadata: - - Name: {parameterName} - - Location: {location} - - Examples: - - For a query parameter named 'filter', return: 'Specifies a filter to narrow results.' - - For a path parameter named 'userId', return: 'Specifies the user ID to retrieve details.' - - Now, generate the description for this parameter."; - - ILanguageModelCompletionResponse? response = null; - - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - } - - // Fallback to the default logic if the language model fails or returns no response - return !string.IsNullOrWhiteSpace(response?.Response) - ? response.Response.Trim() - : GetFallbackParameterDescription(parameterName, location); - } - - private async Task GenerateParameterSummaryAsync(string parameterName, ParameterLocation location) - { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. - The summary must adhere to the following rules: - - Must exist and be written in English. - - Must be free of grammatical and spelling errors. - - Must be 80 characters or less. - - Must contain only alphanumeric characters or parentheses. - - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). - - Parameter Metadata: - - Name: {parameterName} - - Location: {location} - - Examples: - - For a query parameter named 'filter', return: 'Filter results by a specific value.' - - For a path parameter named 'userId', return: 'The unique identifier for a user.' - - Now, generate the summary for this parameter."; - - ILanguageModelCompletionResponse? response = null; - - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - } - - // Fallback to a default summary if the language model fails or returns no response - return !string.IsNullOrWhiteSpace(response?.Response) - ? response.Response.Trim() - : GetFallbackParameterSummary(parameterName, location); - } - - private string GetFallbackParameterSummary(string parameterName, ParameterLocation location) - { - return location switch - { - ParameterLocation.Query => $"Filter results with '{parameterName}'.", - ParameterLocation.Header => $"Provide context with '{parameterName}'.", - ParameterLocation.Path => $"Identify resource with '{parameterName}'.", - ParameterLocation.Cookie => $"Manage session with '{parameterName}'.", - _ => $"Provide info with '{parameterName}'." - }; - } - - private string GetFallbackParameterDescription(string parameterName, ParameterLocation location) - { - return location switch - { - ParameterLocation.Query => $"Specifies the query parameter '{parameterName}' used to filter or modify the request.", - ParameterLocation.Header => $"Specifies the header parameter '{parameterName}' used to provide additional context or metadata.", - ParameterLocation.Path => $"Specifies the path parameter '{parameterName}' required to identify a specific resource.", - ParameterLocation.Cookie => $"Specifies the cookie parameter '{parameterName}' used for session or state management.", - _ => $"Specifies the parameter '{parameterName}' used in the request." - }; - } - - private async Task GetOpenApiDescriptionAsync(string defaultDescription) - { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following OpenAPI document metadata, generate a concise and descriptive summary for the API. - Include the purpose of the API and the types of operations it supports. Respond with just the description. - - OpenAPI Metadata: - - Description: {defaultDescription} - - Rules: - Must exist and be written in English. - Must be free of grammatical and spelling errors. - Should describe concisely the main purpose and value offered by your connector. - Must be longer than 30 characters and shorter than 500 characters. - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). - - Example: - If the API is for managing books, you might respond with: - 'Allows users to manage books, including operations to create, retrieve, update, and delete book records.' - - Now, generate the description for this API."; - - ILanguageModelCompletionResponse? description = null; - - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - description = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - } - - return description?.Response?.Trim() ?? defaultDescription; - } - - private async Task GetOpenApiTitleAsync(string defaultTitle) - { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following guidelines, generate a concise and descriptive title for the API. - The title must meet the following requirements: - - - Must exist and be written in English. - - Must be unique and distinguishable from any existing connector and/or plugin title. - - Should be the name of the product or organization. - - Should follow existing naming patterns for certified connectors and/or plugins. For independent publishers, the connector name should follow the pattern: Connector Name (Independent Publisher). - - Can't be longer than 30 characters. - - Can't contain the words API, Connector, Copilot Studio, or any other Power Platform product names (for example, Power Apps). - - Can't end in a nonalphanumeric character, including carriage return, new line, or blank space. - - Examples: - - Good titles: Azure Sentinel, Office 365 Outlook - - Poor titles: Azure Sentinel's Power Apps Connector, Office 365 Outlook API - - Now, generate a title for the following API: - Default Title: {defaultTitle}"; - - ILanguageModelCompletionResponse? title = null; - - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - title = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - } - - // Fallback to the default title if the language model fails - return title?.Response?.Trim() ?? defaultTitle; - } - - private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl) - { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the corporate website URL for the API. - If the corporate website URL cannot be determined, respond with the default URL provided. - - API Metadata: - - Default URL: {defaultUrl} - - Rules you must follow: - - Do not output any explanations or additional text. - - The URL must be a valid, publicly accessible website. - - The URL must not contain placeholders or invalid characters. - - If no corporate website URL can be determined, return the default URL. - - Example: - Default URL: https://example.com - Response: https://example.com - - Now, determine the corporate website URL for this API."; - - ILanguageModelCompletionResponse? response = null; - - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - } - - // Fallback to the default URL if the language model fails or returns no response - return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; - } - - private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defaultUrl) - { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the privacy policy URL for the corporate website or API. - If the privacy policy URL cannot be determined, respond with the default URL provided. - - API Metadata: - - Default URL: {defaultUrl} - - Rules you must follow: - - Do not output any explanations or additional text. - - The URL must be a valid, publicly accessible website. - - The URL must not contain placeholders or invalid characters. - - If no privacy policy URL can be determined, return the default URL. - - Example: - Response: https://example.com/privacy - - Now, determine the privacy policy URL for this API."; - - ILanguageModelCompletionResponse? response = null; - - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - } - - // Fallback to the default URL if the language model fails or returns no response - return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; - } - - private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, string defaultCategories) - { - var allowedCategories = @"""AI"", ""Business Management"", ""Business Intelligence"", ""Collaboration"", ""Commerce"", ""Communication"", - ""Content and Files"", ""Finance"", ""Data"", ""Human Resources"", ""Internet of Things"", ""IT Operations"", - ""Lifestyle and Entertainment"", ""Marketing"", ""Productivity"", ""Sales and CRM"", ""Security"", - ""Social Media"", ""Website"""; - - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following API metadata and the server URL, determine the most appropriate categories for the API from the allowed list of categories. - If you cannot determine appropriate categories, respond with 'None'. - - API Metadata: - - Server URL: {serverUrl} - - Allowed Categories: {allowedCategories} - - Rules you must follow: - - Do not output any explanations or additional text. - - The categories must be from the allowed list. - - The categories must be relevant to the API's functionality and purpose. - - The categories should be in a comma-separated format. - - If you cannot determine appropriate categories, respond with 'None'. - - Example: - Allowed Categories: AI, Data - Response: Data - - Now, determine the categories for this API."; - - ILanguageModelCompletionResponse? response = null; - - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - } - - // If the response is 'None' or empty, return the default categories - return !string.IsNullOrWhiteSpace(response?.Response) && response.Response.Trim() != "None" - ? response.Response - : defaultCategories; - } - - /** - * Creates an OpenAPI PathItem from an intercepted request and response pair. - * @param session The intercepted session. - */ - private async Task GetOpenApiPathItem(SessionEventArgs session) - { - var request = session.HttpClient.Request; - var response = session.HttpClient.Response; - - var resource = GetLastNonTokenSegment(request.RequestUri.Segments); - var path = new OpenApiPathItem(); - - var method = request.Method?.ToUpperInvariant() switch - { - "DELETE" => OperationType.Delete, - "GET" => OperationType.Get, - "HEAD" => OperationType.Head, - "OPTIONS" => OperationType.Options, - "PATCH" => OperationType.Patch, - "POST" => OperationType.Post, - "PUT" => OperationType.Put, - "TRACE" => OperationType.Trace, - _ => throw new NotSupportedException($"Method {request.Method} is not supported") - }; - var operation = new OpenApiOperation - { - // will be replaced later after the path has been parametrized - Description = $"{method} {resource}", - // will be replaced later after the path has been parametrized - OperationId = $"{method}.{resource}" - }; - await SetParametersFromQueryString(operation, HttpUtility.ParseQueryString(request.RequestUri.Query)); - await SetParametersFromRequestHeaders(operation, request.Headers); - await SetRequestBody(operation, request); - await SetResponseFromSession(operation, response); - - path.Operations.Add(method, operation); - - return path; - } - - private async Task SetRequestBody(OpenApiOperation operation, Request request) - { - if (!request.HasBody) - { - Logger.LogDebug(" Request has no body"); - return; - } - - if (request.ContentType is null) - { - Logger.LogDebug(" Request has no content type"); - return; - } - - Logger.LogDebug(" Processing request body..."); - operation.RequestBody = new OpenApiRequestBody - { - Content = new Dictionary - { - { - GetMediaType(request.ContentType), - new OpenApiMediaType - { - Schema = await GetSchemaFromBody(GetMediaType(request.ContentType), request.BodyString) - } - } - } - }; - } - - private async Task SetParametersFromRequestHeaders(OpenApiOperation operation, HeaderCollection headers) - { - if (headers is null || - !headers.Any()) - { - Logger.LogDebug(" Request has no headers"); - return; - } - - Logger.LogDebug(" Processing request headers..."); - foreach (var header in headers) - { - var lowerCaseHeaderName = header.Name.ToLowerInvariant(); - if (Http.StandardHeaders.Contains(lowerCaseHeaderName)) - { - Logger.LogDebug(" Skipping standard header {headerName}", header.Name); - continue; - } - - if (Http.AuthHeaders.Contains(lowerCaseHeaderName)) - { - Logger.LogDebug(" Skipping auth header {headerName}", header.Name); - continue; - } - - operation.Parameters.Add(new OpenApiParameter - { - Name = header.Name, - In = ParameterLocation.Header, - Required = false, - Schema = new OpenApiSchema { Type = "string" }, - Description = await GenerateParameterDescriptionAsync(header.Name, ParameterLocation.Header), - Extensions = new Dictionary - { - { "x-ms-summary", new OpenApiString(await GenerateParameterSummaryAsync(header.Name, ParameterLocation.Header)) } - } - }); - Logger.LogDebug(" Added header {headerName}", header.Name); - } - } - - private async Task SetParametersFromQueryString(OpenApiOperation operation, NameValueCollection queryParams) - { - if (queryParams.AllKeys is null || - queryParams.AllKeys.Length == 0) - { - Logger.LogDebug(" Request has no query string parameters"); - return; - } - - Logger.LogDebug(" Processing query string parameters..."); - var dictionary = (queryParams.AllKeys as string[]).ToDictionary(k => k, k => queryParams[k] as object); - - foreach (var parameter in dictionary) - { - operation.Parameters.Add(new OpenApiParameter - { - Name = parameter.Key, - In = ParameterLocation.Query, - Required = false, - Schema = new OpenApiSchema { Type = "string" }, - Description = await GenerateParameterDescriptionAsync(parameter.Key, ParameterLocation.Query), - Extensions = new Dictionary - { - { "x-ms-summary", new OpenApiString(await GenerateParameterSummaryAsync(parameter.Key, ParameterLocation.Query)) } - } - }); - Logger.LogDebug(" Added query string parameter {parameterKey}", parameter.Key); - } - } - - private async Task SetResponseFromSession(OpenApiOperation operation, Response response) - { - if (response is null) - { - Logger.LogDebug(" No response to process"); - return; - } - - Logger.LogDebug($" Processing response code {response.StatusCode} for operation {operation}..."); - - var responseCode = response.StatusCode.ToString(); - bool is2xx = response.StatusCode >= 200 && response.StatusCode < 300; - - // Find all 2xx codes already present, sorted numerically - var existing2xxCodes = operation.Responses.Keys - .Where(k => int.TryParse(k, out int code) && code >= 200 && code < 300) - .Select(k => int.Parse(k)) - .OrderBy(k => k) - .ToList(); - - // Determine if this is the lowest 2xx code - bool isLowest2xx = is2xx && (!existing2xxCodes.Any() || response.StatusCode < existing2xxCodes.First()); - - var openApiResponse = new OpenApiResponse - { - Description = isLowest2xx ? "default" : response.StatusDescription - }; - - if (response.HasBody) - { - Logger.LogDebug(" Response has body"); - var mediaType = GetMediaType(response.ContentType); - - if (isLowest2xx) - { - // Only the lowest 2xx response gets a schema - openApiResponse.Content.Add(mediaType, new OpenApiMediaType - { - Schema = await GetSchemaFromBody(mediaType, response.BodyString) - }); - } - else - { - // All other responses: no schema - openApiResponse.Content.Add(mediaType, new OpenApiMediaType()); - } - } - else - { - Logger.LogDebug(" Response doesn't have body"); - } - - // Check configuration before processing headers - if (!_configuration.IncludeResponseHeaders) - { - Logger.LogDebug(" Skipping response headers because IncludeResponseHeaders is set to false"); - } - else if (response.Headers is not null && response.Headers.Any()) - { - Logger.LogDebug(" Response has headers"); - - foreach (var header in response.Headers) - { - var lowerCaseHeaderName = header.Name.ToLowerInvariant(); - if (Http.StandardHeaders.Contains(lowerCaseHeaderName)) - { - Logger.LogDebug(" Skipping standard header {headerName}", header.Name); - continue; - } - - if (Http.AuthHeaders.Contains(lowerCaseHeaderName)) - { - Logger.LogDebug(" Skipping auth header {headerName}", header.Name); - continue; - } - - if (openApiResponse.Headers.ContainsKey(header.Name)) - { - Logger.LogDebug(" Header {headerName} already exists in response", header.Name); - continue; - } - - openApiResponse.Headers.Add(header.Name, new OpenApiHeader - { - Schema = new OpenApiSchema { Type = "string" } - }); - Logger.LogDebug(" Added header {headerName}", header.Name); - } - } - else - { - Logger.LogDebug(" Response doesn't have headers"); - } - - operation.Responses.Add(responseCode, openApiResponse); - } - - private static string GetMediaType(string? contentType) - { - if (string.IsNullOrEmpty(contentType)) - { - return contentType ?? ""; - } - - var mediaType = contentType.Split(';').First().Trim(); - return mediaType; - } - - private async Task GetSchemaFromBody(string? contentType, string body) - { - if (contentType is null) - { - Logger.LogDebug(" No content type to process"); - return null; - } - - if (contentType.StartsWith("application/json")) - { - Logger.LogDebug(" Processing JSON body..."); - return await GetSchemaFromJsonString(body); - } - - return null; - } - - private async Task AddOrMergePathItem(IList openApiDocs, OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) - { - var serverUrl = requestUri.GetLeftPart(UriPartial.Authority); - var openApiDoc = openApiDocs.FirstOrDefault(d => d.Servers.Any(s => s.Url == serverUrl)); - - if (openApiDoc is null) - { - Logger.LogDebug(" Creating OpenAPI spec for {serverUrl}...", serverUrl); - - openApiDoc = new OpenApiDocument - { - Info = new OpenApiInfo - { - Version = "v1.0", - Title = await GetOpenApiTitleAsync($"{serverUrl} API"), - Description = await GetOpenApiDescriptionAsync($"{serverUrl} API"), - Contact = new OpenApiContact - { - Name = _configuration.Contact?.Name ?? "Your Name", - Url = new Uri(_configuration.Contact?.Url ?? "https://www.yourwebsite.com"), - Email = _configuration.Contact?.Email ?? "your.email@yourdomain.com" - } - }, - Servers = - [ - new OpenApiServer { Url = serverUrl } - ], - Paths = [], - Extensions = new Dictionary - { - { "x-ms-connector-metadata", new OpenApiArray - { - new OpenApiObject - { - ["propertyName"] = new OpenApiString("Website"), - ["propertyValue"] = new OpenApiString( - _configuration.ConnectorMetadata?.Website - ?? await GetConnectorMetadataWebsiteUrlAsync(serverUrl)) - }, - new OpenApiObject - { - ["propertyName"] = new OpenApiString("Privacy policy"), - ["propertyValue"] = new OpenApiString( - _configuration.ConnectorMetadata?.PrivacyPolicy - ?? await GetConnectorMetadataPrivacyPolicyUrlAsync(serverUrl)) - }, - new OpenApiObject - { - ["propertyName"] = new OpenApiString("Categories"), - ["propertyValue"] = new OpenApiString( - _configuration.ConnectorMetadata?.Categories - ?? await GetConnectorMetadataCategoriesAsync(serverUrl, "Data")) - } - } - } - } - }; - openApiDocs.Add(openApiDoc); - } - else - { - Logger.LogDebug(" Found OpenAPI spec for {serverUrl}...", serverUrl); - } - - if (!openApiDoc.Paths.TryGetValue(parametrizedPath, out OpenApiPathItem? value)) - { - Logger.LogDebug(" Adding path {parametrizedPath} to OpenAPI spec...", parametrizedPath); - value = pathItem; - openApiDoc.Paths.Add(parametrizedPath, value); - // since we've just added the path, we're done - return; - } - - Logger.LogDebug(" Merging path {parametrizedPath} into OpenAPI spec...", parametrizedPath); - var operation = pathItem.Operations.First(); - AddOrMergeOperation(value, operation.Key, operation.Value); - } - - private void AddOrMergeOperation(OpenApiPathItem pathItem, OperationType operationType, OpenApiOperation apiOperation) - { - if (!pathItem.Operations.TryGetValue(operationType, out OpenApiOperation? value)) - { - Logger.LogDebug(" Adding operation {operationType} to path...", operationType); - - pathItem.AddOperation(operationType, apiOperation); - // since we've just added the operation, we're done - return; - } - - Logger.LogDebug(" Merging operation {operationType} into path...", operationType); - - var operation = value; - - AddOrMergeParameters(operation, apiOperation.Parameters); - AddOrMergeRequestBody(operation, apiOperation.RequestBody); - AddOrMergeResponse(operation, apiOperation.Responses); - } - - private void AddOrMergeParameters(OpenApiOperation operation, IList parameters) - { - if (parameters is null || !parameters.Any()) - { - Logger.LogDebug(" No parameters to process"); - return; - } - - Logger.LogDebug(" Processing parameters for operation..."); - - foreach (var parameter in parameters) - { - var paramFromOperation = operation.Parameters.FirstOrDefault(p => p.Name == parameter.Name && p.In == parameter.In); - if (paramFromOperation is null) - { - Logger.LogDebug(" Adding parameter {parameterName} to operation...", parameter.Name); - - operation.Parameters.Add(parameter); - continue; - } - - Logger.LogDebug(" Merging parameter {parameterName}...", parameter.Name); - MergeSchema(parameter?.Schema, paramFromOperation?.Schema); - } - } - - private void MergeSchema(OpenApiSchema? source, OpenApiSchema? target) - { - if (source is null || target is null) - { - Logger.LogDebug(" Source or target is null. Skipping..."); - return; - } - - if (source.Type != "object" || target.Type != "object") - { - Logger.LogDebug(" Source or target schema is not an object. Skipping..."); - return; - } - - if (source.Properties is null || !source.Properties.Any()) - { - Logger.LogDebug(" Source has no properties. Skipping..."); - return; - } - - if (target.Properties is null || !target.Properties.Any()) - { - Logger.LogDebug(" Target has no properties. Skipping..."); - return; - } - - foreach (var property in source.Properties) - { - var propertyFromTarget = target.Properties.FirstOrDefault(p => p.Key == property.Key); - if (propertyFromTarget.Value is null) - { - Logger.LogDebug(" Adding property {propertyKey} to schema...", property.Key); - target.Properties.Add(property); - continue; - } - - if (property.Value.Type != "object") - { - Logger.LogDebug(" Property already found but is not an object. Skipping..."); - continue; - } - - Logger.LogDebug(" Merging property {propertyKey}...", property.Key); - MergeSchema(property.Value, propertyFromTarget.Value); - } - } - - private void AddOrMergeRequestBody(OpenApiOperation operation, OpenApiRequestBody requestBody) - { - if (requestBody is null || !requestBody.Content.Any()) - { - Logger.LogDebug(" No request body to process"); - return; - } - - var requestBodyType = requestBody.Content.FirstOrDefault().Key; - operation.RequestBody.Content.TryGetValue(requestBodyType, out OpenApiMediaType? bodyFromOperation); - - if (bodyFromOperation is null) - { - Logger.LogDebug(" Adding request body to operation..."); - - operation.RequestBody.Content.Add(requestBody.Content.FirstOrDefault()); - // since we've just added the request body, we're done - return; - } - - Logger.LogDebug(" Merging request body into operation..."); - MergeSchema(bodyFromOperation.Schema, requestBody.Content.FirstOrDefault().Value.Schema); - } - - private void AddOrMergeResponse(OpenApiOperation operation, OpenApiResponses apiResponses) - { - if (apiResponses is null) - { - Logger.LogDebug(" No response to process"); - return; - } - - var apiResponseInfo = apiResponses.FirstOrDefault(); - var apiResponseStatusCode = apiResponseInfo.Key; - var apiResponse = apiResponseInfo.Value; - operation.Responses.TryGetValue(apiResponseStatusCode, out OpenApiResponse? responseFromOperation); - - if (responseFromOperation is null) - { - Logger.LogDebug(" Adding response {apiResponseStatusCode} to operation...", apiResponseStatusCode); - - operation.Responses.Add(apiResponseStatusCode, apiResponse); - // since we've just added the response, we're done - return; - } - - if (!apiResponse.Content.Any()) - { - Logger.LogDebug(" No response content to process"); - return; - } - - var apiResponseContentType = apiResponse.Content.First().Key; - responseFromOperation.Content.TryGetValue(apiResponseContentType, out OpenApiMediaType? contentFromOperation); - - if (contentFromOperation is null) - { - Logger.LogDebug(" Adding response {apiResponseContentType} to {apiResponseStatusCode} to response...", apiResponseContentType, apiResponseStatusCode); - - responseFromOperation.Content.Add(apiResponse.Content.First()); - // since we've just added the content, we're done - return; - } - - Logger.LogDebug(" Merging response {apiResponseStatusCode}/{apiResponseContentType} into operation...", apiResponseStatusCode, apiResponseContentType); - MergeSchema(contentFromOperation.Schema, apiResponse.Content.First().Value.Schema); - } - - private static string GetFileNameFromServerUrl(string serverUrl, SpecFormat format) - { - var uri = new Uri(serverUrl); - var ext = format switch - { - SpecFormat.Json => "json", - SpecFormat.Yaml => "yaml", - _ => "json" - }; - var fileName = $"{uri.Host}-{DateTime.Now:yyyyMMddHHmmss}.{ext}"; - return fileName; - } - - private async Task GetSchemaFromJsonString(string jsonString) - { - try - { - using var doc = JsonDocument.Parse(jsonString); - JsonElement root = doc.RootElement; - var schema = await GetSchemaFromJsonElement(root); - return schema; - } - catch - { - return new OpenApiSchema - { - Type = "object" - }; - } - } - - private async Task GetResponsePropertyTitleAsync(string propertyName) - { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable title for the property. - The title must: - - Be in Title Case (capitalize the first letter of each word). - - Be 2-5 words long. - - Not include underscores, dashes, or technical jargon. - - Not repeat the property name verbatim if it contains underscores or is not human-friendly. - - Be clear, descriptive, and suitable for use as a 'title' in OpenAPI schema properties. - - Examples: - Property Name: tenant_id - Title: Tenant ID - - Property Name: event_type - Title: Event Type - - Property Name: created_at - Title: Created At - - Property Name: user_email_address - Title: User Email Address - - Now, generate a title for this property: - Property Name: {propertyName} - Title: - "; - - ILanguageModelCompletionResponse? response = null; - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.3 }); - } - return !string.IsNullOrWhiteSpace(response?.Response) - ? response.Response.Trim() - : GetResponsePropertyTitleFallback(propertyName); - } - - // Fallback if LLM fails - private static string GetResponsePropertyTitleFallback(string propertyName) - { - return string.Join(" ", propertyName - .Replace("_", " ") - .Replace("-", " ") - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Select(word => char.ToUpperInvariant(word[0]) + word.Substring(1))); - } - - private async Task GetResponsePropertyDescriptionAsync(string propertyName) - { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable description for the property. - The description must: - - Be a full, descriptive sentence and end in punctuation. - - Be written in English. - - Be free of grammatical and spelling errors. - - Clearly explain the purpose or meaning of the property. - - Not repeat the property name verbatim if it contains underscores or is not human-friendly. - - Be suitable for use as a 'description' in OpenAPI schema properties. - - Only return the description, without any additional text or explanation. - - Examples: - Property Name: tenant_id - Description: The ID of the tenant this notification belongs to. - - Property Name: event_type - Description: The type of the event. - - Property Name: created_at - Description: The timestamp of when the event was generated. - - Property Name: user_email_address - Description: The email address of the user who triggered the event. - - Now, generate a description for this property: - Property Name: {propertyName} - Description: - "; - - ILanguageModelCompletionResponse? response = null; - if (await Context.LanguageModelClient.IsEnabledAsync()) - { - response = await Context.LanguageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.3 }); - } - return !string.IsNullOrWhiteSpace(response?.Response) - ? response.Response.Trim() - : GetResponsePropertyDescriptionFallback(propertyName); - } - - // Fallback if LLM fails - private static string GetResponsePropertyDescriptionFallback(string propertyName) - { - // Simple fallback: "The value of {Property Name}." - var title = string.Join(" ", propertyName - .Replace("_", " ") - .Replace("-", " ") - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Select(word => char.ToUpperInvariant(word[0]) + word.Substring(1))); - return $"The value of {title}."; - } - - private async Task GetSchemaFromJsonElement(JsonElement jsonElement, string? propertyName = null) - { - // Log the start of processing this element - Logger.LogDebug("Processing JSON element{0}{1}", - propertyName != null ? $" for property '{propertyName}'" : string.Empty, - $", ValueKind: {jsonElement.ValueKind}"); - - var schema = new OpenApiSchema(); - switch (jsonElement.ValueKind) - { - case JsonValueKind.String: - schema.Type = "string"; - schema.Title = await GetResponsePropertyTitleAsync(propertyName ?? string.Empty); - Logger.LogDebug(" Set type 'string' for property '{propertyName}'", propertyName); - break; - case JsonValueKind.Number: - schema.Type = "number"; - schema.Title = await GetResponsePropertyTitleAsync(propertyName ?? string.Empty); - Logger.LogDebug(" Set type 'number' for property '{propertyName}'", propertyName); - break; - case JsonValueKind.True: - case JsonValueKind.False: - schema.Type = "boolean"; - schema.Title = await GetResponsePropertyTitleAsync(propertyName ?? string.Empty); - Logger.LogDebug(" Set type 'boolean' for property '{propertyName}'", propertyName); - break; - case JsonValueKind.Object: - schema.Type = "object"; - schema.Properties = new Dictionary(); - Logger.LogDebug(" Processing object properties for '{propertyName}'", propertyName); - foreach (var prop in jsonElement.EnumerateObject()) - { - schema.Properties[prop.Name] = await GetSchemaFromJsonElement(prop.Value, prop.Name); - } - break; - case JsonValueKind.Array: - schema.Type = "array"; - Logger.LogDebug(" Processing array items for '{propertyName}'", propertyName); - schema.Items = await GetSchemaFromJsonElement(jsonElement.EnumerateArray().FirstOrDefault(), propertyName); - break; - default: - schema.Type = "object"; - Logger.LogDebug(" Set default type 'object' for property '{propertyName}'", propertyName); - break; - } - schema.Description = await GetResponsePropertyDescriptionAsync(propertyName ?? string.Empty); - Logger.LogDebug(" Set description for property '{propertyName}': {description}", propertyName, schema.Description); - return schema; - } -} diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index e08bb241..f9de754f 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -50,7 +50,7 @@ public enum SpecFormat Yaml } -public sealed class OpenApiSpecGeneratorPluginConfiguration +public class OpenApiSpecGeneratorPluginConfiguration { public bool IncludeOptionsRequests { get; set; } public SpecFormat SpecFormat { get; set; } = SpecFormat.Json; @@ -138,6 +138,9 @@ request.Context.Session is null || var generatedOpenApiSpecs = new Dictionary(); foreach (var openApiDoc in openApiDocs) { + // Allow derived plugins to post-process the OpenApiDocument (above the path level) + ProcessOpenApiDocument(openApiDoc); + var server = openApiDoc.Servers.First(); var fileName = GetFileNameFromServerUrl(server.Url, Configuration.SpecFormat); @@ -191,6 +194,15 @@ protected virtual OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri return pathItem; } + /// + /// Allows derived plugins to post-process the OpenApiDocument before it is serialized and written to disk. + /// + /// The OpenApiDocument to process. + protected virtual void ProcessOpenApiDocument(OpenApiDocument openApiDoc) + { + // By default, do nothing. Derived plugins can override to add/modify document-level data. + } + private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) { ILanguageModelCompletionResponse? id = null; diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index 0115059c..66efa1d3 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -23,9 +23,18 @@ public class ConnectorMetadataConfig public string? Categories { get; set; } } +public class PowerPlatformOpenApiSpecGeneratorPluginConfiguration : OpenApiSpecGeneratorPluginConfiguration +{ + public ContactConfig Contact { get; set; } = new(); + public ConnectorMetadataConfig ConnectorMetadata { get; set; } = new(); + public bool IncludeResponseHeaders { get; set; } +} + public class PowerPlatformOpenApiSpecGeneratorPlugin : OpenApiSpecGeneratorPlugin { private readonly ILanguageModelClient _languageModelClient; + private readonly PowerPlatformOpenApiSpecGeneratorPluginConfiguration _configuration; + #pragma warning disable IDE0290 // Use primary constructor public PowerPlatformOpenApiSpecGeneratorPlugin( @@ -38,6 +47,9 @@ IConfigurationSection pluginConfigurationSection ) : base(logger, urlsToWatch, languageModelClient, proxyConfiguration, pluginConfigurationSection) { _languageModelClient = languageModelClient; + _configuration = pluginConfigurationSection.Get() + ?? new(); + Configuration.SpecVersion = SpecVersion.v2_0; } @@ -48,15 +60,381 @@ protected override OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri ArgumentNullException.ThrowIfNull(pathItem); ArgumentNullException.ThrowIfNull(requestUri); - // // Generate and add connector metadata - // var serverUrl = requestUri.GetLeftPart(UriPartial.Authority); - // // Synchronously wait for the async method (not recommended for production, but matches signature) - // var connectorMetadata = GenerateConnectorMetadataAsync(serverUrl).GetAwaiter().GetResult(); - // pathItem.Extensions["x-ms-connector-metadata"] = connectorMetadata; - + // Synchronously invoke the async details processor + ProcessPathItemDetailsAsync(pathItem, requestUri, parametrizedPath).GetAwaiter().GetResult(); return pathItem; } + protected override void ProcessOpenApiDocument(OpenApiDocument openApiDoc) + { + ArgumentNullException.ThrowIfNull(openApiDoc); + SetContactInfo(openApiDoc); + SetTitleAndDescription(openApiDoc); + + // Try to get the server URL from the OpenAPI document + var serverUrl = openApiDoc.Servers?.FirstOrDefault()?.Url; + if (string.IsNullOrWhiteSpace(serverUrl)) + { + // If no server URL, do not add metadata + return; + } + + // Synchronously call the async metadata generator + var metadata = GenerateConnectorMetadataAsync(serverUrl).GetAwaiter().GetResult(); + openApiDoc.Extensions["x-ms-connector-metadata"] = metadata; + RemoveConnectorMetadataExtension(openApiDoc); + } + + /// + /// Sets the OpenApi title and description in the Info area of the OpenApiDocument using LLM-generated values. + /// + private void SetTitleAndDescription(OpenApiDocument openApiDoc) + { + // Synchronously call the async title/description generators + var defaultTitle = openApiDoc.Info?.Title ?? "API"; + var defaultDescription = openApiDoc.Info?.Description ?? "API description."; + var title = GetOpenApiTitleAsync(defaultTitle).GetAwaiter().GetResult(); + var description = GetOpenApiDescriptionAsync(defaultDescription).GetAwaiter().GetResult(); + openApiDoc.Info ??= new OpenApiInfo(); + openApiDoc.Info.Title = title; + openApiDoc.Info.Description = description; + } + + /// + /// Sets the OpenApiContact in the Info area of the OpenApiDocument using configuration values. + /// + private void SetContactInfo(OpenApiDocument openApiDoc) + { + openApiDoc.Info.Contact = new OpenApiContact + { + Name = _configuration.Contact?.Name ?? "Your Name", + Url = Uri.TryCreate(_configuration.Contact?.Url, UriKind.Absolute, out var url) ? url : new Uri("https://www.yourwebsite.com"), + Email = _configuration.Contact?.Email ?? "your.email@yourdomain.com" + }; + } + + + private async Task GetOpenApiDescriptionAsync(string defaultDescription) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following OpenAPI document metadata, generate a concise and descriptive summary for the API. + Include the purpose of the API and the types of operations it supports. Respond with just the description. + + OpenAPI Metadata: + - Description: {defaultDescription} + + Rules: + Must exist and be written in English. + Must be free of grammatical and spelling errors. + Should describe concisely the main purpose and value offered by your connector. + Must be longer than 30 characters and shorter than 500 characters. + Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). + + Example: + If the API is for managing books, you might respond with: + 'Allows users to manage books, including operations to create, retrieve, update, and delete book records.' + + Now, generate the description for this API."; + + ILanguageModelCompletionResponse? description = null; + + if (await _languageModelClient.IsEnabledAsync()) + { + description = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + return description?.Response?.Trim() ?? defaultDescription; + } + + private async Task GetOpenApiTitleAsync(string defaultTitle) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following guidelines, generate a concise and descriptive title for the API. + The title must meet the following requirements: + + - Must exist and be written in English. + - Must be unique and distinguishable from any existing connector and/or plugin title. + - Should be the name of the product or organization. + - Should follow existing naming patterns for certified connectors and/or plugins. For independent publishers, the connector name should follow the pattern: Connector Name (Independent Publisher). + - Can't be longer than 30 characters. + - Can't contain the words API, Connector, Copilot Studio, or any other Power Platform product names (for example, Power Apps). + - Can't end in a nonalphanumeric character, including carriage return, new line, or blank space. + + Examples: + - Good titles: Azure Sentinel, Office 365 Outlook + - Poor titles: Azure Sentinel's Power Apps Connector, Office 365 Outlook API + + Now, generate a title for the following API: + Default Title: {defaultTitle}"; + + ILanguageModelCompletionResponse? title = null; + + if (await _languageModelClient.IsEnabledAsync()) + { + title = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to the default title if the language model fails + return title?.Response?.Trim() ?? defaultTitle; + } + + + /// + /// Processes all operations, parameters, and responses for a single OpenApiPathItem. + /// + private async Task ProcessPathItemDetailsAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + { + var serverUrl = $"{requestUri.Scheme}://{requestUri.Host}{(requestUri.IsDefaultPort ? "" : ":" + requestUri.Port)}"; + foreach (var (method, operation) in pathItem.Operations) + { + // Update operationId + operation.OperationId = await GetOperationIdAsync(method.ToString(), serverUrl, parametrizedPath); + + // Update summary + operation.Summary = await GetOperationSummaryAsync(method.ToString(), serverUrl, parametrizedPath); + + // Update description + operation.Description = await GetOperationDescriptionAsync(method.ToString(), serverUrl, parametrizedPath); + + // Process parameters + if (operation.Parameters != null) + { + foreach (var parameter in operation.Parameters) + { + parameter.Description = await GenerateParameterDescriptionAsync(parameter.Name, parameter.In); + parameter.Extensions["x-ms-summary"] = new OpenApiString(await GenerateParameterSummaryAsync(parameter.Name, parameter.In)); + } + } + + // Process responses + if (operation.Responses != null) + { + foreach (var response in operation.Responses.Values) + { + if (response.Content != null) + { + foreach (var mediaType in response.Content.Values) + { + if (mediaType.Schema != null) + { + await ProcessSchemaPropertiesAsync(mediaType.Schema); + } + } + } + } + } + } + RemoveResponseHeadersIfDisabled(pathItem); + } + + /// + /// Removes all response headers from the OpenApiPathItem if IncludeResponseHeaders is false. + /// + private void RemoveResponseHeadersIfDisabled(OpenApiPathItem pathItem) + { + if (!_configuration.IncludeResponseHeaders && pathItem != null) + { + foreach (var operation in pathItem.Operations.Values) + { + if (operation.Responses != null) + { + foreach (var response in operation.Responses.Values) + { + response.Headers?.Clear(); + } + } + } + } + } + + private async Task ProcessSchemaPropertiesAsync(OpenApiSchema schema) + { + if (schema.Properties != null) + { + foreach (var (propertyName, propertySchema) in schema.Properties) + { + propertySchema.Title = await GetResponsePropertyTitleAsync(propertyName); + propertySchema.Description = await GetResponsePropertyDescriptionAsync(propertyName); + // Recursively process nested schemas + await ProcessSchemaPropertiesAsync(propertySchema); + } + } + // Handle array items + if (schema.Items != null) + { + await ProcessSchemaPropertiesAsync(schema.Items); + } + } + private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) + { + var prompt = @"**Prompt:** + Generate an operation ID for an OpenAPI specification based on the HTTP method and URL provided. Follow these rules: + - The operation ID should be in camelCase format. + - Start with a verb that matches the HTTP method (e.g., `get`, `create`, `update`, `delete`). + - Use descriptive words from the URL path. + - Replace path parameters (e.g., `{userId}`) with relevant nouns in singular form (e.g., `User`). + - Do not provide explanations or any other text; respond only with the operation ID. + + Example: + **Request:** `GET https://api.contoso.com/books/{books-id}` + getBook + + Example: + **Request:** `GET https://api.contoso.com/books/{books-id}/authors` + getBookAuthors + + Example: + **Request:** `GET https://api.contoso.com/books/{books-id}/authors/{authors-id}` + getBookAuthor + + Example: + **Request:** `POST https://api.contoso.com/books/{books-id}/authors` + addBookAuthor + + Now, generate the operation ID for the following: + **Request:** `{request}`".Replace("{request}", $"{method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}", StringComparison.InvariantCulture); + ILanguageModelCompletionResponse? id = null; + if (await _languageModelClient.IsEnabledAsync()) + { + id = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 1 }); + } + return id?.Response?.Trim() ?? $"{method}{parametrizedPath.Replace('/', '.')}"; + } + + private async Task GetOperationSummaryAsync(string method, string serverUrl, string parametrizedPath) + { + var prompt = $@"You're an expert in OpenAPI. + You help developers build great OpenAPI specs for use with LLMs. + For the specified request, generate a concise, one-sentence summary that adheres to the following rules: + - Must exist and be written in English. + - Must be a phrase and cannot not end with punctuation. + - Must be free of grammatical and spelling errors. + - Must be 80 characters or less. + - Must contain only alphanumeric characters or parentheses. + - Must not include the words API, Connector, or any other Power Platform product names (for example, Power Apps). + - Respond with just the summary. + + For example: + - For a request such as `GET https://api.contoso.com/books/{{books-id}}`, return `Get a book by ID` + - For a request such as `POST https://api.contoso.com/books`, return `Create a new book` + + Request: {method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}"; + ILanguageModelCompletionResponse? description = null; + if (await _languageModelClient.IsEnabledAsync()) + { + description = await _languageModelClient.GenerateCompletionAsync(prompt); + } + return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; + } + + private async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath) + { + var prompt = $@"You're an expert in OpenAPI. + You help developers build great OpenAPI specs for use with LLMs. + For the specified request, generate a one-sentence description that ends in punctuation. + Respond with just the description. + For example, for a request such as `GET https://api.contoso.com/books/{{books-id}}` + // you return `Get a book by ID`. Request: {method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}"; + ILanguageModelCompletionResponse? description = null; + if (await _languageModelClient.IsEnabledAsync()) + { + description = await _languageModelClient.GenerateCompletionAsync(prompt); + } + return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; + } + + private async Task GenerateParameterDescriptionAsync(string parameterName, ParameterLocation? location) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. + The description must adhere to the following rules: + - Must exist and be written in English. + - Must be a full, descriptive sentence, and end in punctuation. + - Must be free of grammatical and spelling errors. + - Must describe the purpose of the parameter and its role in the request. + - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). + + Parameter Metadata: + - Name: {parameterName} + - Location: {location} + + Examples: + - For a query parameter named 'filter', return: 'Specifies a filter to narrow results.' + - For a path parameter named 'userId', return: 'Specifies the user ID to retrieve details.' + + Now, generate the description for this parameter."; + + ILanguageModelCompletionResponse? response = null; + + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to the default logic if the language model fails or returns no response + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetFallbackParameterDescription(parameterName, location); + } + + private async Task GenerateParameterSummaryAsync(string parameterName, ParameterLocation? location) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. + The summary must adhere to the following rules: + - Must exist and be written in English. + - Must be free of grammatical and spelling errors. + - Must be 80 characters or less. + - Must contain only alphanumeric characters or parentheses. + - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). + + Parameter Metadata: + - Name: {parameterName} + - Location: {location} + + Examples: + - For a query parameter named 'filter', return: 'Filter results by a specific value.' + - For a path parameter named 'userId', return: 'The unique identifier for a user.' + + Now, generate the summary for this parameter."; + + ILanguageModelCompletionResponse? response = null; + + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to a default summary if the language model fails or returns no response + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetFallbackParameterSummary(parameterName, location); + } + + private static string GetFallbackParameterSummary(string parameterName, ParameterLocation? location) + { + return location switch + { + ParameterLocation.Query => $"Filter results with '{parameterName}'.", + ParameterLocation.Header => $"Provide context with '{parameterName}'.", + ParameterLocation.Path => $"Identify resource with '{parameterName}'.", + ParameterLocation.Cookie => $"Manage session with '{parameterName}'.", + _ => $"Provide info with '{parameterName}'." + }; + } + + private static string GetFallbackParameterDescription(string parameterName, ParameterLocation? location) + { + return location switch + { + ParameterLocation.Query => $"Specifies the query parameter '{parameterName}' used to filter or modify the request.", + ParameterLocation.Header => $"Specifies the header parameter '{parameterName}' used to provide additional context or metadata.", + ParameterLocation.Path => $"Specifies the path parameter '{parameterName}' required to identify a specific resource.", + ParameterLocation.Cookie => $"Specifies the cookie parameter '{parameterName}' used for session or state management.", + _ => $"Specifies the parameter '{parameterName}' used in the request." + }; + } + private async Task GetResponsePropertyTitleAsync(string propertyName) { var prompt = $@" @@ -170,138 +548,146 @@ private static string GetResponsePropertyDescriptionFallback(string propertyName return $"The value of {description}."; } + private async Task GenerateConnectorMetadataAsync(string serverUrl) + { + var website = _configuration.ConnectorMetadata?.Website ?? await GetConnectorMetadataWebsiteUrlAsync(serverUrl); + var privacyPolicy = _configuration.ConnectorMetadata?.PrivacyPolicy ?? await GetConnectorMetadataPrivacyPolicyUrlAsync(serverUrl); + var categories = _configuration.ConnectorMetadata?.Categories ?? await GetConnectorMetadataCategoriesAsync(serverUrl, "Data"); + + var metadataArray = new OpenApiArray + { + new OpenApiObject + { + ["propertyName"] = new OpenApiString("Website"), + ["propertyValue"] = new OpenApiString(website) + }, + new OpenApiObject + { + ["propertyName"] = new OpenApiString("Privacy policy"), + ["propertyValue"] = new OpenApiString(privacyPolicy) + }, + new OpenApiObject + { + ["propertyName"] = new OpenApiString("Categories"), + ["propertyValue"] = new OpenApiString(categories) + } + }; + return metadataArray; + } + + private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the corporate website URL for the API. + If the corporate website URL cannot be determined, respond with the default URL provided. + + API Metadata: + - Default URL: {defaultUrl} + + Rules you must follow: + - Do not output any explanations or additional text. + - The URL must be a valid, publicly accessible website. + - The URL must not contain placeholders or invalid characters. + - If no corporate website URL can be determined, return the default URL. + + Example: + Default URL: https://example.com + Response: https://example.com + + Now, determine the corporate website URL for this API."; + + ILanguageModelCompletionResponse? response = null; + + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to the default URL if the language model fails or returns no response + return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; + } + + private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defaultUrl) + { + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the privacy policy URL for the corporate website or API. + If the privacy policy URL cannot be determined, respond with the default URL provided. + + API Metadata: + - Default URL: {defaultUrl} + + Rules you must follow: + - Do not output any explanations or additional text. + - The URL must be a valid, publicly accessible website. + - The URL must not contain placeholders or invalid characters. + - If no privacy policy URL can be determined, return the default URL. + Example: + Response: https://example.com/privacy - // private async Task GenerateConnectorMetadataAsync(string serverUrl) - // { - // var website = await GetConnectorMetadataWebsiteUrlAsync(serverUrl); - // var privacyPolicy = await GetConnectorMetadataPrivacyPolicyUrlAsync(serverUrl); - // var categories = await GetConnectorMetadataCategoriesAsync(serverUrl, "Data"); - - // var metadataArray = new OpenApiArray - // { - // new OpenApiObject - // { - // ["propertyName"] = new OpenApiString("Website"), - // ["propertyValue"] = new OpenApiString(website) - // }, - // new OpenApiObject - // { - // ["propertyName"] = new OpenApiString("Privacy policy"), - // ["propertyValue"] = new OpenApiString(privacyPolicy) - // }, - // new OpenApiObject - // { - // ["propertyName"] = new OpenApiString("Categories"), - // ["propertyValue"] = new OpenApiString(categories) - // } - // }; - // return metadataArray; - // } - - // private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl) - // { - // var prompt = $@" - // You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the corporate website URL for the API. - // If the corporate website URL cannot be determined, respond with the default URL provided. - - // API Metadata: - // - Default URL: {defaultUrl} - - // Rules you must follow: - // - Do not output any explanations or additional text. - // - The URL must be a valid, publicly accessible website. - // - The URL must not contain placeholders or invalid characters. - // - If no corporate website URL can be determined, return the default URL. - - // Example: - // Default URL: https://example.com - // Response: https://example.com - - // Now, determine the corporate website URL for this API."; - - // ILanguageModelCompletionResponse? response = null; - - // if (await _languageModelClient.IsEnabledAsync()) - // { - // response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - // } - - // // Fallback to the default URL if the language model fails or returns no response - // return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; - // } - - // private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defaultUrl) - // { - // var prompt = $@" - // You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the privacy policy URL for the corporate website or API. - // If the privacy policy URL cannot be determined, respond with the default URL provided. - - // API Metadata: - // - Default URL: {defaultUrl} - - // Rules you must follow: - // - Do not output any explanations or additional text. - // - The URL must be a valid, publicly accessible website. - // - The URL must not contain placeholders or invalid characters. - // - If no privacy policy URL can be determined, return the default URL. - - // Example: - // Response: https://example.com/privacy - - // Now, determine the privacy policy URL for this API."; - - // ILanguageModelCompletionResponse? response = null; - - // if (await _languageModelClient.IsEnabledAsync()) - // { - // response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - // } - - // // Fallback to the default URL if the language model fails or returns no response - // return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; - // } - - // private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, string defaultCategories) - // { - // var allowedCategories = @"""AI"", ""Business Management"", ""Business Intelligence"", ""Collaboration"", ""Commerce"", ""Communication"", - // ""Content and Files"", ""Finance"", ""Data"", ""Human Resources"", ""Internet of Things"", ""IT Operations"", - // ""Lifestyle and Entertainment"", ""Marketing"", ""Productivity"", ""Sales and CRM"", ""Security"", - // ""Social Media"", ""Website"""; - - // var prompt = $@" - // You're an expert in OpenAPI and API documentation. Based on the following API metadata and the server URL, determine the most appropriate categories for the API from the allowed list of categories. - // If you cannot determine appropriate categories, respond with 'None'. - - // API Metadata: - // - Server URL: {serverUrl} - // - Allowed Categories: {allowedCategories} - - // Rules you must follow: - // - Do not output any explanations or additional text. - // - The categories must be from the allowed list. - // - The categories must be relevant to the API's functionality and purpose. - // - The categories should be in a comma-separated format. - // - If you cannot determine appropriate categories, respond with 'None'. - - // Example: - // Allowed Categories: AI, Data - // Response: Data - - // Now, determine the categories for this API."; - - // ILanguageModelCompletionResponse? response = null; - - // if (await _languageModelClient.IsEnabledAsync()) - // { - // response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); - // } + Now, determine the privacy policy URL for this API."; - // // If the response is 'None' or empty, return the default categories - // return !string.IsNullOrWhiteSpace(response?.Response) && response.Response.Trim() != "None" - // ? response.Response - // : defaultCategories; - // } + ILanguageModelCompletionResponse? response = null; + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // Fallback to the default URL if the language model fails or returns no response + return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; + } + + private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, string defaultCategories) + { + var allowedCategories = @"""AI"", ""Business Management"", ""Business Intelligence"", ""Collaboration"", ""Commerce"", ""Communication"", + ""Content and Files"", ""Finance"", ""Data"", ""Human Resources"", ""Internet of Things"", ""IT Operations"", + ""Lifestyle and Entertainment"", ""Marketing"", ""Productivity"", ""Sales and CRM"", ""Security"", + ""Social Media"", ""Website"""; + + var prompt = $@" + You're an expert in OpenAPI and API documentation. Based on the following API metadata and the server URL, determine the most appropriate categories for the API from the allowed list of categories. + If you cannot determine appropriate categories, respond with 'None'. + + API Metadata: + - Server URL: {serverUrl} + - Allowed Categories: {allowedCategories} + + Rules you must follow: + - Do not output any explanations or additional text. + - The categories must be from the allowed list. + - The categories must be relevant to the API's functionality and purpose. + - The categories should be in a comma-separated format. + - If you cannot determine appropriate categories, respond with 'None'. + + Example: + Allowed Categories: AI, Data + Response: Data + + Now, determine the categories for this API."; + + ILanguageModelCompletionResponse? response = null; + + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + } + + // If the response is 'None' or empty, return the default categories + return !string.IsNullOrWhiteSpace(response?.Response) && response.Response.Trim() != "None" + ? response.Response + : defaultCategories; + } + + /// + /// Removes the x-ms-connector-metadata extension from the OpenAPI document if it exists. + /// + private static void RemoveConnectorMetadataExtension(OpenApiDocument openApiDoc) + { + if (openApiDoc?.Extensions != null && openApiDoc.Extensions.ContainsKey("x-ms-connector-metadata")) + { + _ = openApiDoc.Extensions.Remove("x-ms-connector-metadata"); + } + } } diff --git a/schemas/v0.27.0/powerplatformspecgeneratorplugin.schema.json b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json similarity index 94% rename from schemas/v0.27.0/powerplatformspecgeneratorplugin.schema.json rename to schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json index f79e5f2a..3fe73a1b 100644 --- a/schemas/v0.27.0/powerplatformspecgeneratorplugin.schema.json +++ b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Dev Proxy PowerPlatformSpecGeneratorPlugin config schema", + "title": "Dev Proxy PowerPlatformOpenApiSpecGeneratorPlugin config schema", "type": "object", "properties": { "$schema": { @@ -19,7 +19,7 @@ ], "description": "Specifies the format of the generated OpenAPI spec. Allowed values: 'Json' or 'Yaml'. Default: 'Json'." }, - "includeRequestHeaders": { + "includeResponseHeaders": { "type": "boolean", "description": "Determines whether to include request headers in the generated OpenAPI spec. Default: false." }, From 04ddcbe389399c057ffeb9c6bf5a63d11be8c178 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 00:09:24 -0400 Subject: [PATCH 05/24] fix: Update synchronous processing of path item details and improve parameter handling in PowerPlatformOpenApiSpecGeneratorPlugin --- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index 66efa1d3..f320e9a0 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -61,7 +61,7 @@ protected override OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri ArgumentNullException.ThrowIfNull(requestUri); // Synchronously invoke the async details processor - ProcessPathItemDetailsAsync(pathItem, requestUri, parametrizedPath).GetAwaiter().GetResult(); + //ProcessPathItemDetailsAsync(pathItem, requestUri, parametrizedPath).GetAwaiter().GetResult(); return pathItem; } @@ -79,6 +79,12 @@ protected override void ProcessOpenApiDocument(OpenApiDocument openApiDoc) return; } + foreach (var (path, pathItem) in openApiDoc.Paths) + { + // You can pass the path string if needed + ProcessPathItemDetailsAsync(pathItem, new Uri(serverUrl), path).GetAwaiter().GetResult(); + } + // Synchronously call the async metadata generator var metadata = GenerateConnectorMetadataAsync(serverUrl).GetAwaiter().GetResult(); openApiDoc.Extensions["x-ms-connector-metadata"] = metadata; @@ -196,14 +202,15 @@ private async Task ProcessPathItemDetailsAsync(OpenApiPathItem pathItem, Uri req // Update description operation.Description = await GetOperationDescriptionAsync(method.ToString(), serverUrl, parametrizedPath); - // Process parameters - if (operation.Parameters != null) + // Combine operation-level and path-level parameters + var allParameters = new List(); + allParameters.AddRange(operation.Parameters); + allParameters.AddRange(pathItem.Parameters); + + foreach (var parameter in allParameters) { - foreach (var parameter in operation.Parameters) - { - parameter.Description = await GenerateParameterDescriptionAsync(parameter.Name, parameter.In); - parameter.Extensions["x-ms-summary"] = new OpenApiString(await GenerateParameterSummaryAsync(parameter.Name, parameter.In)); - } + parameter.Description = await GenerateParameterDescriptionAsync(parameter.Name, parameter.In); + parameter.Extensions["x-ms-summary"] = new OpenApiString(await GenerateParameterSummaryAsync(parameter.Name, parameter.In)); } // Process responses @@ -684,9 +691,10 @@ private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, /// private static void RemoveConnectorMetadataExtension(OpenApiDocument openApiDoc) { - if (openApiDoc?.Extensions != null && openApiDoc.Extensions.ContainsKey("x-ms-connector-metadata")) + if (openApiDoc?.Extensions != null && openApiDoc.Extensions.ContainsKey("x-ms-generated-by")) { - _ = openApiDoc.Extensions.Remove("x-ms-connector-metadata"); + // Remove the x-ms-generated-by extension if it exists + _ = openApiDoc.Extensions.Remove("x-ms-generated-by"); } } From b15277b33c51b18106ce1240f8565b4e47bb7bcc Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 00:35:22 -0400 Subject: [PATCH 06/24] feat: Enhance PowerPlatformOpenApiSpecGeneratorPlugin with detailed XML documentation and improved path item processing --- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 177 ++++++++++++++---- 1 file changed, 141 insertions(+), 36 deletions(-) diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index f320e9a0..bbf9f01b 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -6,7 +6,6 @@ using Microsoft.OpenApi.Any; using System.Globalization; - namespace DevProxy.Plugins.Generation; public class ContactConfig @@ -52,19 +51,32 @@ IConfigurationSection pluginConfigurationSection Configuration.SpecVersion = SpecVersion.v2_0; } - public override string Name => nameof(PowerPlatformOpenApiSpecGeneratorPlugin); + /// + /// Processes a single OpenAPI path item to set operation details, parameter descriptions, and response properties. + /// This method is called synchronously during the OpenAPI document processing. + /// + /// The OpenAPI path item to process. + /// The request URI for context. + /// The parametrized path for the operation. + /// The processed OpenAPI path item. protected override OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { ArgumentNullException.ThrowIfNull(pathItem); ArgumentNullException.ThrowIfNull(requestUri); // Synchronously invoke the async details processor - //ProcessPathItemDetailsAsync(pathItem, requestUri, parametrizedPath).GetAwaiter().GetResult(); + ProcessPathItemDetailsAsync(pathItem, requestUri, parametrizedPath).GetAwaiter().GetResult(); return pathItem; } + /// + /// Processes the OpenAPI document to set contact information, title, description, and connector metadata. + /// This method is called synchronously during the OpenAPI document processing. + /// + /// The OpenAPI document to process. + /// Thrown if is null. protected override void ProcessOpenApiDocument(OpenApiDocument openApiDoc) { ArgumentNullException.ThrowIfNull(openApiDoc); @@ -73,17 +85,7 @@ protected override void ProcessOpenApiDocument(OpenApiDocument openApiDoc) // Try to get the server URL from the OpenAPI document var serverUrl = openApiDoc.Servers?.FirstOrDefault()?.Url; - if (string.IsNullOrWhiteSpace(serverUrl)) - { - // If no server URL, do not add metadata - return; - } - - foreach (var (path, pathItem) in openApiDoc.Paths) - { - // You can pass the path string if needed - ProcessPathItemDetailsAsync(pathItem, new Uri(serverUrl), path).GetAwaiter().GetResult(); - } + ArgumentNullException.ThrowIfNull(serverUrl); // Synchronously call the async metadata generator var metadata = GenerateConnectorMetadataAsync(serverUrl).GetAwaiter().GetResult(); @@ -94,6 +96,7 @@ protected override void ProcessOpenApiDocument(OpenApiDocument openApiDoc) /// /// Sets the OpenApi title and description in the Info area of the OpenApiDocument using LLM-generated values. /// + /// The OpenAPI document to process. private void SetTitleAndDescription(OpenApiDocument openApiDoc) { // Synchronously call the async title/description generators @@ -109,6 +112,7 @@ private void SetTitleAndDescription(OpenApiDocument openApiDoc) /// /// Sets the OpenApiContact in the Info area of the OpenApiDocument using configuration values. /// + /// The OpenAPI document to process. private void SetContactInfo(OpenApiDocument openApiDoc) { openApiDoc.Info.Contact = new OpenApiContact @@ -119,7 +123,11 @@ private void SetContactInfo(OpenApiDocument openApiDoc) }; } - + /// + /// Removes the x-ms-connector-metadata extension from the OpenAPI document if it exists + /// and is empty. + /// + /// The OpenAPI document to process. private async Task GetOpenApiDescriptionAsync(string defaultDescription) { var prompt = $@" @@ -152,6 +160,10 @@ Must be longer than 30 characters and shorter than 500 characters. return description?.Response?.Trim() ?? defaultDescription; } + /// + /// Generates a concise and descriptive title for the OpenAPI document using LLM or fallback logic. + /// + /// The default title to use if LLM generation fails. private async Task GetOpenApiTitleAsync(string defaultTitle) { var prompt = $@" @@ -184,10 +196,12 @@ private async Task GetOpenApiTitleAsync(string defaultTitle) return title?.Response?.Trim() ?? defaultTitle; } - /// /// Processes all operations, parameters, and responses for a single OpenApiPathItem. /// + /// The OpenAPI path item to process. + /// The request URI for context. + /// The parametrized path for the operation. private async Task ProcessPathItemDetailsAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { var serverUrl = $"{requestUri.Scheme}://{requestUri.Host}{(requestUri.IsDefaultPort ? "" : ":" + requestUri.Port)}"; @@ -235,25 +249,9 @@ private async Task ProcessPathItemDetailsAsync(OpenApiPathItem pathItem, Uri req } /// - /// Removes all response headers from the OpenApiPathItem if IncludeResponseHeaders is false. + /// Recursively processes all properties of an , setting their title and description using LLM or fallback logic. /// - private void RemoveResponseHeadersIfDisabled(OpenApiPathItem pathItem) - { - if (!_configuration.IncludeResponseHeaders && pathItem != null) - { - foreach (var operation in pathItem.Operations.Values) - { - if (operation.Responses != null) - { - foreach (var response in operation.Responses.Values) - { - response.Headers?.Clear(); - } - } - } - } - } - + /// The OpenAPI schema to process. private async Task ProcessSchemaPropertiesAsync(OpenApiSchema schema) { if (schema.Properties != null) @@ -272,6 +270,35 @@ private async Task ProcessSchemaPropertiesAsync(OpenApiSchema schema) await ProcessSchemaPropertiesAsync(schema.Items); } } + + /// + /// Removes all response headers from the if IncludeResponseHeaders is false in the configuration. + /// + /// The OpenAPI path item to process. + private void RemoveResponseHeadersIfDisabled(OpenApiPathItem pathItem) + { + if (!_configuration.IncludeResponseHeaders && pathItem != null) + { + foreach (var operation in pathItem.Operations.Values) + { + if (operation.Responses != null) + { + foreach (var response in operation.Responses.Values) + { + response.Headers?.Clear(); + } + } + } + } + } + + /// + /// Generates an operationId for an OpenAPI operation using LLM or fallback logic. + /// + /// The HTTP method. + /// The server URL. + /// The parametrized path. + /// The generated operationId. private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) { var prompt = @"**Prompt:** @@ -308,6 +335,13 @@ private async Task GetOperationIdAsync(string method, string serverUrl, return id?.Response?.Trim() ?? $"{method}{parametrizedPath.Replace('/', '.')}"; } + /// + /// Generates a summary for an OpenAPI operation using LLM or fallback logic. + /// + /// The HTTP method. + /// The server URL. + /// The parametrized path. + /// The generated summary. private async Task GetOperationSummaryAsync(string method, string serverUrl, string parametrizedPath) { var prompt = $@"You're an expert in OpenAPI. @@ -334,6 +368,13 @@ private async Task GetOperationSummaryAsync(string method, string server return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; } + /// + /// Generates a description for an OpenAPI operation using LLM or fallback logic. + /// + /// The HTTP method. + /// The server URL. + /// The parametrized path. + /// The generated description. private async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath) { var prompt = $@"You're an expert in OpenAPI. @@ -350,6 +391,12 @@ Respond with just the description. return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; } + /// + /// Generates a description for an OpenAPI parameter using LLM or fallback logic. + /// + /// The parameter name. + /// The parameter location. + /// The generated description. private async Task GenerateParameterDescriptionAsync(string parameterName, ParameterLocation? location) { var prompt = $@" @@ -384,6 +431,12 @@ private async Task GenerateParameterDescriptionAsync(string parameterNam : GetFallbackParameterDescription(parameterName, location); } + /// + /// Generates a summary for an OpenAPI parameter using LLM or fallback logic. + /// + /// The parameter name. + /// The parameter location. + /// The generated summary. private async Task GenerateParameterSummaryAsync(string parameterName, ParameterLocation? location) { var prompt = $@" @@ -418,6 +471,12 @@ private async Task GenerateParameterSummaryAsync(string parameterName, P : GetFallbackParameterSummary(parameterName, location); } + /// + /// Returns a fallback summary for a parameter if LLM generation fails. + /// + /// The parameter name. + /// The parameter location. + /// The fallback summary string. private static string GetFallbackParameterSummary(string parameterName, ParameterLocation? location) { return location switch @@ -430,6 +489,12 @@ private static string GetFallbackParameterSummary(string parameterName, Paramete }; } + /// + /// Returns a fallback description for a parameter if LLM generation fails. + /// + /// The parameter name. + /// The parameter location. + /// The fallback description string. private static string GetFallbackParameterDescription(string parameterName, ParameterLocation? location) { return location switch @@ -442,6 +507,11 @@ private static string GetFallbackParameterDescription(string parameterName, Para }; } + /// + /// Generates a title for a response property using LLM or fallback logic. + /// + /// The property name. + /// The generated title. private async Task GetResponsePropertyTitleAsync(string propertyName) { var prompt = $@" @@ -481,7 +551,11 @@ private async Task GetResponsePropertyTitleAsync(string propertyName) : GetResponsePropertyTitleFallback(propertyName); } - // Fallback if LLM fails + /// + /// Returns a fallback title for a response property if LLM generation fails. + /// + /// The property name. + /// The fallback title string. private static string GetResponsePropertyTitleFallback(string propertyName) { // Replace underscores and dashes with spaces, then ensure all lowercase before capitalizing @@ -497,6 +571,11 @@ private static string GetResponsePropertyTitleFallback(string propertyName) return title; } + /// + /// Generates a description for a response property using LLM or fallback logic. + /// + /// The property name. + /// The generated description. private async Task GetResponsePropertyDescriptionAsync(string propertyName) { var prompt = $@" @@ -538,7 +617,11 @@ private async Task GetResponsePropertyDescriptionAsync(string propertyNa : GetResponsePropertyDescriptionFallback(propertyName); } - // Fallback if LLM fails + /// + /// Returns a fallback description for a response property if LLM generation fails. + /// + /// The property name. + /// The fallback description string. private static string GetResponsePropertyDescriptionFallback(string propertyName) { // Convert underscores and dashes to spaces, then ensure all lowercase before capitalizing @@ -555,6 +638,11 @@ private static string GetResponsePropertyDescriptionFallback(string propertyName return $"The value of {description}."; } + /// + /// Generates the connector metadata OpenAPI extension array using configuration and LLM. + /// + /// The server URL for context. + /// An containing connector metadata. private async Task GenerateConnectorMetadataAsync(string serverUrl) { var website = _configuration.ConnectorMetadata?.Website ?? await GetConnectorMetadataWebsiteUrlAsync(serverUrl); @@ -582,6 +670,11 @@ private async Task GenerateConnectorMetadataAsync(string serverUrl return metadataArray; } + /// + /// Generates the website URL for connector metadata using LLM or configuration. + /// + /// The default URL to use if LLM fails. + /// The website URL. private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl) { var prompt = $@" @@ -614,6 +707,11 @@ private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; } + /// + /// Generates the privacy policy URL for connector metadata using LLM or configuration. + /// + /// The default URL to use if LLM fails. + /// The privacy policy URL. private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defaultUrl) { var prompt = $@" @@ -645,6 +743,12 @@ private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defa return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; } + /// + /// Generates the categories for connector metadata using LLM or configuration. + /// + /// The server URL for context. + /// The default categories to use if LLM fails. + /// The categories string. private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, string defaultCategories) { var allowedCategories = @"""AI"", ""Business Management"", ""Business Intelligence"", ""Collaboration"", ""Commerce"", ""Communication"", @@ -689,6 +793,7 @@ private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, /// /// Removes the x-ms-connector-metadata extension from the OpenAPI document if it exists. /// + /// The OpenAPI document to process. private static void RemoveConnectorMetadataExtension(OpenApiDocument openApiDoc) { if (openApiDoc?.Extensions != null && openApiDoc.Extensions.ContainsKey("x-ms-generated-by")) From 4bdeece10382c9c9f52fe9bf453ff564bfcf06e3 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 00:38:49 -0400 Subject: [PATCH 07/24] fix: Improve logging for OpenAPI docs serialization in OpenApiSpecGeneratorPlugin --- .../Generation/OpenApiSpecGeneratorPlugin.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index f9de754f..054342fc 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -134,7 +134,7 @@ request.Context.Session is null || } } - // Serialize and write OpenAPI docs + Logger.LogDebug("Serializing OpenAPI docs..."); var generatedOpenApiSpecs = new Dictionary(); foreach (var openApiDoc in openApiDocs) { @@ -168,11 +168,11 @@ request.Context.Session is null || StoreReport(new OpenApiSpecGeneratorPluginReport( generatedOpenApiSpecs - .Select(kvp => new OpenApiSpecGeneratorPluginReportItem - { - ServerUrl = kvp.Key, - FileName = kvp.Value - })), e); + .Select(kvp => new OpenApiSpecGeneratorPluginReportItem + { + ServerUrl = kvp.Key, + FileName = kvp.Value + })), e); // store the generated OpenAPI specs in the global data // for use by other plugins From 16dbc64e5f323b5e14f27d92d4654d5c2e86c70b Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 00:42:28 -0400 Subject: [PATCH 08/24] fix: Update comment in ProcessPathItem method to clarify extensibility for derived plugins --- DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 054342fc..e3c3ca92 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -190,7 +190,7 @@ request.Context.Session is null || /// The processed OpenApiPathItem. protected virtual OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { - // By default, return the path item unchanged. + // By default, return the path item unchanged. Derived plugins can override to add/modify path-level data. return pathItem; } From ad1edf78544f08c13e6343a59c90a1a69672a07b Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 08:41:08 -0400 Subject: [PATCH 09/24] fix: Rename $schema property to $schemaReference for clarity in schema definition --- .../v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json index 3fe73a1b..ccd0093a 100644 --- a/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json +++ b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json @@ -3,7 +3,7 @@ "title": "Dev Proxy PowerPlatformOpenApiSpecGeneratorPlugin config schema", "type": "object", "properties": { - "$schema": { + "$schemaReference": { "type": "string", "description": "The JSON schema reference for validation." }, From c31dff03b01620bdbbdf9086076415a9a9ba89dd Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 08:57:39 -0400 Subject: [PATCH 10/24] fix: Update ConnectorMetadataConfig to use IReadOnlyList for Categories and adjust schema to reflect array type --- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 19 +++++++++++++++++-- ...formopenapispecgeneratorplugin.schema.json | 8 ++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index bbf9f01b..6c219d04 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -19,7 +19,12 @@ public class ConnectorMetadataConfig { public string? Website { get; set; } public string? PrivacyPolicy { get; set; } - public string? Categories { get; set; } + private string[]? _categories; + public IReadOnlyList? Categories + { + get => _categories; + set => _categories = value?.ToArray(); + } } public class PowerPlatformOpenApiSpecGeneratorPluginConfiguration : OpenApiSpecGeneratorPluginConfiguration @@ -647,7 +652,17 @@ private async Task GenerateConnectorMetadataAsync(string serverUrl { var website = _configuration.ConnectorMetadata?.Website ?? await GetConnectorMetadataWebsiteUrlAsync(serverUrl); var privacyPolicy = _configuration.ConnectorMetadata?.PrivacyPolicy ?? await GetConnectorMetadataPrivacyPolicyUrlAsync(serverUrl); - var categories = _configuration.ConnectorMetadata?.Categories ?? await GetConnectorMetadataCategoriesAsync(serverUrl, "Data"); + + string categories; + var categoriesList = _configuration.ConnectorMetadata?.Categories; + if (categoriesList != null && categoriesList.Count > 0) + { + categories = string.Join(", ", categoriesList); + } + else + { + categories = await GetConnectorMetadataCategoriesAsync(serverUrl, "Data"); + } var metadataArray = new OpenApiArray { diff --git a/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json index ccd0093a..b3787b22 100644 --- a/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json +++ b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json @@ -59,8 +59,12 @@ "description": "The privacy policy URL for the API." }, "categories": { - "type": "string", - "description": "Comma-separated categories for the API." + "type": "array", + "description": "An array of categories for the API.", + "items": { + "type": "string", + "description": "A category for the API." + } } }, "additionalProperties": false From 5e5976dbf32f7c4bc19aaf4cb5ffb9c2d323e22c Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 14:39:12 -0400 Subject: [PATCH 11/24] feat: Introduce new configuration classes and prompts for OpenAPI specifications, enhancing metadata handling and API documentation generation --- .../Generation/OpenApiSpecGeneratorPlugin.cs | 24 +- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 336 +++--------------- DevProxy/DevProxy.csproj | 33 ++ ..._api_connector_metadata_categories.prompty | 53 +++ ..._connector_metadata_privacy_policy.prompty | 30 ++ ...orm_api_connector_metadata_website.prompty | 31 ++ .../powerplatform_api_description.prompty | 29 ++ ...platform_api_operation_description.prompty | 18 + .../powerplatform_api_operation_id.prompty | 36 ++ ...platform_api_parameter_description.prompty | 31 ++ ...owerplatform_api_parameter_summary.prompty | 25 ++ ..._api_response_property_description.prompty | 38 ++ ...atform_api_response_property_title.prompty | 35 ++ .../prompts/powerplatform_api_title.prompty | 30 ++ 14 files changed, 470 insertions(+), 279 deletions(-) create mode 100644 DevProxy/prompts/powerplatform_api_connector_metadata_categories.prompty create mode 100644 DevProxy/prompts/powerplatform_api_connector_metadata_privacy_policy.prompty create mode 100644 DevProxy/prompts/powerplatform_api_connector_metadata_website.prompty create mode 100644 DevProxy/prompts/powerplatform_api_description.prompty create mode 100644 DevProxy/prompts/powerplatform_api_operation_description.prompty create mode 100644 DevProxy/prompts/powerplatform_api_operation_id.prompty create mode 100644 DevProxy/prompts/powerplatform_api_parameter_description.prompty create mode 100644 DevProxy/prompts/powerplatform_api_parameter_summary.prompty create mode 100644 DevProxy/prompts/powerplatform_api_response_property_description.prompty create mode 100644 DevProxy/prompts/powerplatform_api_response_property_title.prompty create mode 100644 DevProxy/prompts/powerplatform_api_title.prompty diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index e3c3ca92..423a15be 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -50,11 +50,33 @@ public enum SpecFormat Yaml } -public class OpenApiSpecGeneratorPluginConfiguration +public class ContactConfig +{ + public string Name { get; set; } = "Your Name"; + public string Url { get; set; } = "https://www.yourwebsite.com"; + public string Email { get; set; } = "your.email@yourdomain.com"; +} + +public class ConnectorMetadataConfig +{ + public string? Website { get; set; } + public string? PrivacyPolicy { get; set; } + private string[]? _categories; + public IReadOnlyList? Categories + { + get => _categories; + set => _categories = value?.ToArray(); + } +} + +public sealed class OpenApiSpecGeneratorPluginConfiguration { public bool IncludeOptionsRequests { get; set; } public SpecFormat SpecFormat { get; set; } = SpecFormat.Json; public SpecVersion SpecVersion { get; set; } = SpecVersion.v3_0; + public ContactConfig Contact { get; set; } = new(); + public ConnectorMetadataConfig ConnectorMetadata { get; set; } = new(); + public bool IncludeResponseHeaders { get; set; } } public class OpenApiSpecGeneratorPlugin( diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index 6c219d04..9092462b 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -8,37 +8,9 @@ namespace DevProxy.Plugins.Generation; -public class ContactConfig -{ - public string Name { get; set; } = "Your Name"; - public string Url { get; set; } = "https://www.yourwebsite.com"; - public string Email { get; set; } = "your.email@yourdomain.com"; -} - -public class ConnectorMetadataConfig -{ - public string? Website { get; set; } - public string? PrivacyPolicy { get; set; } - private string[]? _categories; - public IReadOnlyList? Categories - { - get => _categories; - set => _categories = value?.ToArray(); - } -} - -public class PowerPlatformOpenApiSpecGeneratorPluginConfiguration : OpenApiSpecGeneratorPluginConfiguration -{ - public ContactConfig Contact { get; set; } = new(); - public ConnectorMetadataConfig ConnectorMetadata { get; set; } = new(); - public bool IncludeResponseHeaders { get; set; } -} - -public class PowerPlatformOpenApiSpecGeneratorPlugin : OpenApiSpecGeneratorPlugin +public sealed class PowerPlatformOpenApiSpecGeneratorPlugin : OpenApiSpecGeneratorPlugin { private readonly ILanguageModelClient _languageModelClient; - private readonly PowerPlatformOpenApiSpecGeneratorPluginConfiguration _configuration; - #pragma warning disable IDE0290 // Use primary constructor public PowerPlatformOpenApiSpecGeneratorPlugin( @@ -51,8 +23,6 @@ IConfigurationSection pluginConfigurationSection ) : base(logger, urlsToWatch, languageModelClient, proxyConfiguration, pluginConfigurationSection) { _languageModelClient = languageModelClient; - _configuration = pluginConfigurationSection.Get() - ?? new(); Configuration.SpecVersion = SpecVersion.v2_0; } @@ -90,7 +60,11 @@ protected override void ProcessOpenApiDocument(OpenApiDocument openApiDoc) // Try to get the server URL from the OpenAPI document var serverUrl = openApiDoc.Servers?.FirstOrDefault()?.Url; - ArgumentNullException.ThrowIfNull(serverUrl); + + if (string.IsNullOrWhiteSpace(serverUrl)) + { + throw new InvalidOperationException("No server URL found in the OpenAPI document. Please ensure the document contains at least one server definition."); + } // Synchronously call the async metadata generator var metadata = GenerateConnectorMetadataAsync(serverUrl).GetAwaiter().GetResult(); @@ -122,9 +96,9 @@ private void SetContactInfo(OpenApiDocument openApiDoc) { openApiDoc.Info.Contact = new OpenApiContact { - Name = _configuration.Contact?.Name ?? "Your Name", - Url = Uri.TryCreate(_configuration.Contact?.Url, UriKind.Absolute, out var url) ? url : new Uri("https://www.yourwebsite.com"), - Email = _configuration.Contact?.Email ?? "your.email@yourdomain.com" + Name = Configuration.Contact?.Name ?? "Your Name", + Url = Uri.TryCreate(Configuration.Contact?.Url, UriKind.Absolute, out var url) ? url : new Uri("https://www.yourwebsite.com"), + Email = Configuration.Contact?.Email ?? "your.email@yourdomain.com" }; } @@ -135,31 +109,14 @@ private void SetContactInfo(OpenApiDocument openApiDoc) /// The OpenAPI document to process. private async Task GetOpenApiDescriptionAsync(string defaultDescription) { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following OpenAPI document metadata, generate a concise and descriptive summary for the API. - Include the purpose of the API and the types of operations it supports. Respond with just the description. - - OpenAPI Metadata: - - Description: {defaultDescription} - - Rules: - Must exist and be written in English. - Must be free of grammatical and spelling errors. - Should describe concisely the main purpose and value offered by your connector. - Must be longer than 30 characters and shorter than 500 characters. - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). - - Example: - If the API is for managing books, you might respond with: - 'Allows users to manage books, including operations to create, retrieve, update, and delete book records.' - - Now, generate the description for this API."; - ILanguageModelCompletionResponse? description = null; if (await _languageModelClient.IsEnabledAsync()) { - description = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + description = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_description", new() + { + { "description", defaultDescription } + }); } return description?.Response?.Trim() ?? defaultDescription; @@ -171,30 +128,13 @@ Must be longer than 30 characters and shorter than 500 characters. /// The default title to use if LLM generation fails. private async Task GetOpenApiTitleAsync(string defaultTitle) { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following guidelines, generate a concise and descriptive title for the API. - The title must meet the following requirements: - - - Must exist and be written in English. - - Must be unique and distinguishable from any existing connector and/or plugin title. - - Should be the name of the product or organization. - - Should follow existing naming patterns for certified connectors and/or plugins. For independent publishers, the connector name should follow the pattern: Connector Name (Independent Publisher). - - Can't be longer than 30 characters. - - Can't contain the words API, Connector, Copilot Studio, or any other Power Platform product names (for example, Power Apps). - - Can't end in a nonalphanumeric character, including carriage return, new line, or blank space. - - Examples: - - Good titles: Azure Sentinel, Office 365 Outlook - - Poor titles: Azure Sentinel's Power Apps Connector, Office 365 Outlook API - - Now, generate a title for the following API: - Default Title: {defaultTitle}"; - ILanguageModelCompletionResponse? title = null; if (await _languageModelClient.IsEnabledAsync()) { - title = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + title = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_title", new() { + { "defaultTitle", defaultTitle } + }); } // Fallback to the default title if the language model fails @@ -282,7 +222,7 @@ private async Task ProcessSchemaPropertiesAsync(OpenApiSchema schema) /// The OpenAPI path item to process. private void RemoveResponseHeadersIfDisabled(OpenApiPathItem pathItem) { - if (!_configuration.IncludeResponseHeaders && pathItem != null) + if (!Configuration.IncludeResponseHeaders && pathItem != null) { foreach (var operation in pathItem.Operations.Values) { @@ -306,36 +246,13 @@ private void RemoveResponseHeadersIfDisabled(OpenApiPathItem pathItem) /// The generated operationId. private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) { - var prompt = @"**Prompt:** - Generate an operation ID for an OpenAPI specification based on the HTTP method and URL provided. Follow these rules: - - The operation ID should be in camelCase format. - - Start with a verb that matches the HTTP method (e.g., `get`, `create`, `update`, `delete`). - - Use descriptive words from the URL path. - - Replace path parameters (e.g., `{userId}`) with relevant nouns in singular form (e.g., `User`). - - Do not provide explanations or any other text; respond only with the operation ID. - - Example: - **Request:** `GET https://api.contoso.com/books/{books-id}` - getBook - - Example: - **Request:** `GET https://api.contoso.com/books/{books-id}/authors` - getBookAuthors - - Example: - **Request:** `GET https://api.contoso.com/books/{books-id}/authors/{authors-id}` - getBookAuthor - - Example: - **Request:** `POST https://api.contoso.com/books/{books-id}/authors` - addBookAuthor - - Now, generate the operation ID for the following: - **Request:** `{request}`".Replace("{request}", $"{method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}", StringComparison.InvariantCulture); ILanguageModelCompletionResponse? id = null; if (await _languageModelClient.IsEnabledAsync()) { - id = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 1 }); + id = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_operation_id", new() + { + { "request", $"{method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}" } + }); } return id?.Response?.Trim() ?? $"{method}{parametrizedPath.Replace('/', '.')}"; } @@ -382,16 +299,13 @@ private async Task GetOperationSummaryAsync(string method, string server /// The generated description. private async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath) { - var prompt = $@"You're an expert in OpenAPI. - You help developers build great OpenAPI specs for use with LLMs. - For the specified request, generate a one-sentence description that ends in punctuation. - Respond with just the description. - For example, for a request such as `GET https://api.contoso.com/books/{{books-id}}` - // you return `Get a book by ID`. Request: {method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}"; ILanguageModelCompletionResponse? description = null; if (await _languageModelClient.IsEnabledAsync()) { - description = await _languageModelClient.GenerateCompletionAsync(prompt); + description = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_operation_description", new() + { + { "request", $"{method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}" } + }); } return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; } @@ -404,30 +318,15 @@ Respond with just the description. /// The generated description. private async Task GenerateParameterDescriptionAsync(string parameterName, ParameterLocation? location) { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. - The description must adhere to the following rules: - - Must exist and be written in English. - - Must be a full, descriptive sentence, and end in punctuation. - - Must be free of grammatical and spelling errors. - - Must describe the purpose of the parameter and its role in the request. - - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). - - Parameter Metadata: - - Name: {parameterName} - - Location: {location} - - Examples: - - For a query parameter named 'filter', return: 'Specifies a filter to narrow results.' - - For a path parameter named 'userId', return: 'Specifies the user ID to retrieve details.' - - Now, generate the description for this parameter."; - ILanguageModelCompletionResponse? response = null; if (await _languageModelClient.IsEnabledAsync()) { - response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_parameter_description", new() + { + { "parameterName", parameterName }, + { "location", location?.ToString() ?? "unknown" } + }); } // Fallback to the default logic if the language model fails or returns no response @@ -444,30 +343,15 @@ private async Task GenerateParameterDescriptionAsync(string parameterNam /// The generated summary. private async Task GenerateParameterSummaryAsync(string parameterName, ParameterLocation? location) { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. - The summary must adhere to the following rules: - - Must exist and be written in English. - - Must be free of grammatical and spelling errors. - - Must be 80 characters or less. - - Must contain only alphanumeric characters or parentheses. - - Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). - - Parameter Metadata: - - Name: {parameterName} - - Location: {location} - - Examples: - - For a query parameter named 'filter', return: 'Filter results by a specific value.' - - For a path parameter named 'userId', return: 'The unique identifier for a user.' - - Now, generate the summary for this parameter."; - ILanguageModelCompletionResponse? response = null; if (await _languageModelClient.IsEnabledAsync()) { - response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_parameter_summary", new() + { + { "parameterName", parameterName }, + { "location", location?.ToString() ?? "unknown" } + }); } // Fallback to a default summary if the language model fails or returns no response @@ -519,37 +403,13 @@ private static string GetFallbackParameterDescription(string parameterName, Para /// The generated title. private async Task GetResponsePropertyTitleAsync(string propertyName) { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable title for the property. - The title must: - - Be in Title Case (capitalize the first letter of each word). - - Be 2-5 words long. - - Not include underscores, dashes, or technical jargon. - - Not repeat the property name verbatim if it contains underscores or is not human-friendly. - - Be clear, descriptive, and suitable for use as a 'title' in OpenAPI schema properties. - - Examples: - Property Name: tenant_id - Title: Tenant ID - - Property Name: event_type - Title: Event Type - - Property Name: created_at - Title: Created At - - Property Name: user_email_address - Title: User Email Address - - Now, generate a title for this property: - Property Name: {propertyName} - Title: - "; - ILanguageModelCompletionResponse? response = null; if (await _languageModelClient.IsEnabledAsync()) { - response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.3 }); + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_response_property_title", new() + { + { "propertyName", propertyName } + }); } return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() @@ -583,39 +443,13 @@ private static string GetResponsePropertyTitleFallback(string propertyName) /// The generated description. private async Task GetResponsePropertyDescriptionAsync(string propertyName) { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable description for the property. - The description must: - - Be a full, descriptive sentence and end in punctuation. - - Be written in English. - - Be free of grammatical and spelling errors. - - Clearly explain the purpose or meaning of the property. - - Not repeat the property name verbatim if it contains underscores or is not human-friendly. - - Be suitable for use as a 'description' in OpenAPI schema properties. - - Only return the description, without any additional text or explanation. - - Examples: - Property Name: tenant_id - Description: The ID of the tenant this notification belongs to. - - Property Name: event_type - Description: The type of the event. - - Property Name: created_at - Description: The timestamp of when the event was generated. - - Property Name: user_email_address - Description: The email address of the user who triggered the event. - - Now, generate a description for this property: - Property Name: {propertyName} - Description: - "; - ILanguageModelCompletionResponse? response = null; if (await _languageModelClient.IsEnabledAsync()) { - response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.3 }); + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_response_property_description", new() + { + { "propertyName", propertyName } + }); } return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() @@ -650,11 +484,11 @@ private static string GetResponsePropertyDescriptionFallback(string propertyName /// An containing connector metadata. private async Task GenerateConnectorMetadataAsync(string serverUrl) { - var website = _configuration.ConnectorMetadata?.Website ?? await GetConnectorMetadataWebsiteUrlAsync(serverUrl); - var privacyPolicy = _configuration.ConnectorMetadata?.PrivacyPolicy ?? await GetConnectorMetadataPrivacyPolicyUrlAsync(serverUrl); + var website = Configuration.ConnectorMetadata?.Website ?? await GetConnectorMetadataWebsiteUrlAsync(serverUrl); + var privacyPolicy = Configuration.ConnectorMetadata?.PrivacyPolicy ?? await GetConnectorMetadataPrivacyPolicyUrlAsync(serverUrl); string categories; - var categoriesList = _configuration.ConnectorMetadata?.Categories; + var categoriesList = Configuration.ConnectorMetadata?.Categories; if (categoriesList != null && categoriesList.Count > 0) { categories = string.Join(", ", categoriesList); @@ -692,30 +526,14 @@ private async Task GenerateConnectorMetadataAsync(string serverUrl /// The website URL. private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl) { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the corporate website URL for the API. - If the corporate website URL cannot be determined, respond with the default URL provided. - - API Metadata: - - Default URL: {defaultUrl} - - Rules you must follow: - - Do not output any explanations or additional text. - - The URL must be a valid, publicly accessible website. - - The URL must not contain placeholders or invalid characters. - - If no corporate website URL can be determined, return the default URL. - - Example: - Default URL: https://example.com - Response: https://example.com - - Now, determine the corporate website URL for this API."; - ILanguageModelCompletionResponse? response = null; if (await _languageModelClient.IsEnabledAsync()) { - response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_connector_metadata_website", new() + { + { "defaultUrl", defaultUrl } + }); } // Fallback to the default URL if the language model fails or returns no response @@ -729,29 +547,14 @@ private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl /// The privacy policy URL. private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defaultUrl) { - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the privacy policy URL for the corporate website or API. - If the privacy policy URL cannot be determined, respond with the default URL provided. - - API Metadata: - - Default URL: {defaultUrl} - - Rules you must follow: - - Do not output any explanations or additional text. - - The URL must be a valid, publicly accessible website. - - The URL must not contain placeholders or invalid characters. - - If no privacy policy URL can be determined, return the default URL. - - Example: - Response: https://example.com/privacy - - Now, determine the privacy policy URL for this API."; - ILanguageModelCompletionResponse? response = null; if (await _languageModelClient.IsEnabledAsync()) { - response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_connector_metadata_privacy_policy", new() + { + { "defaultUrl", defaultUrl } + }); } // Fallback to the default URL if the language model fails or returns no response @@ -766,37 +569,14 @@ private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defa /// The categories string. private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, string defaultCategories) { - var allowedCategories = @"""AI"", ""Business Management"", ""Business Intelligence"", ""Collaboration"", ""Commerce"", ""Communication"", - ""Content and Files"", ""Finance"", ""Data"", ""Human Resources"", ""Internet of Things"", ""IT Operations"", - ""Lifestyle and Entertainment"", ""Marketing"", ""Productivity"", ""Sales and CRM"", ""Security"", - ""Social Media"", ""Website"""; - - var prompt = $@" - You're an expert in OpenAPI and API documentation. Based on the following API metadata and the server URL, determine the most appropriate categories for the API from the allowed list of categories. - If you cannot determine appropriate categories, respond with 'None'. - - API Metadata: - - Server URL: {serverUrl} - - Allowed Categories: {allowedCategories} - - Rules you must follow: - - Do not output any explanations or additional text. - - The categories must be from the allowed list. - - The categories must be relevant to the API's functionality and purpose. - - The categories should be in a comma-separated format. - - If you cannot determine appropriate categories, respond with 'None'. - - Example: - Allowed Categories: AI, Data - Response: Data - - Now, determine the categories for this API."; - ILanguageModelCompletionResponse? response = null; if (await _languageModelClient.IsEnabledAsync()) { - response = await _languageModelClient.GenerateCompletionAsync(prompt, new() { Temperature = 0.7 }); + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_connector_metadata_categories", new() + { + { "serverUrl", serverUrl } + }); } // If the response is 'None' or empty, return the default categories diff --git a/DevProxy/DevProxy.csproj b/DevProxy/DevProxy.csproj index 5c3ec24d..e6c977a7 100644 --- a/DevProxy/DevProxy.csproj +++ b/DevProxy/DevProxy.csproj @@ -64,6 +64,39 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/DevProxy/prompts/powerplatform_api_connector_metadata_categories.prompty b/DevProxy/prompts/powerplatform_api_connector_metadata_categories.prompty new file mode 100644 index 00000000..d92e1efc --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_connector_metadata_categories.prompty @@ -0,0 +1,53 @@ +--- +name: OpenAPI allowed categories +description: Determine the most appropriate categories for an API from the Microsoft Power Platform allowed list. +authors: + - Dev Proxy +model: + api: chat +sample: + request: | + Server URL: https://api.example.com + Response: Data +--- + +system: +You're an expert in OpenAPI and API documentation. Based on the following API metadata and the server URL, determine the most appropriate categories for the API from the allowed list of categories. If you cannot determine appropriate categories, respond with 'None'. + +API Metadata: +- Server URL: {{serverUrl}} + +Allowed Categories: +- AI +- Business Management +- Business Intelligence +- Collaboration +- Commerce +- Communication +- Content and Files +- Finance +- Data +- Human Resources +- Internet of Things +- IT Operations +- Lifestyle and Entertainment +- Marketing +- Productivity +- Sales and CRM +- Security +- Social Media +- Website + +Rules you must follow: +- Do not output any explanations or additional text. +- The categories must be from the allowed list. +- The categories must be relevant to the API's functionality and purpose. +- The categories should be in a comma-separated format. +- If you cannot determine appropriate categories, respond with 'None'. + +Example: +Server URL: https://api.example.com +Response: Data + +user: +Server URL: {{serverUrl}} \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_connector_metadata_privacy_policy.prompty b/DevProxy/prompts/powerplatform_api_connector_metadata_privacy_policy.prompty new file mode 100644 index 00000000..a6237a06 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_connector_metadata_privacy_policy.prompty @@ -0,0 +1,30 @@ +--- +name: OpenAPI privacy policy URL +description: Determine the privacy policy URL for an API based on provided metadata. +authors: + - Dev Proxy +model: + api: chat +sample: + request: | + Default URL: https://example.com/privacy + Response: https://example.com/privacy +--- + +system: +You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the privacy policy URL for the corporate website or API. If the privacy policy URL cannot be determined, respond with the default URL provided. + +API Metadata: +- Default URL: {{defaultUrl}} + +Rules you must follow: +- Do not output any explanations or additional text. +- The URL must be a valid, publicly accessible website. +- The URL must not contain placeholders or invalid characters. +- If no privacy policy URL can be determined, return the default URL. + +Example: +Response: https://example.com/privacy + +user: +Default URL: {{defaultUrl}} \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_connector_metadata_website.prompty b/DevProxy/prompts/powerplatform_api_connector_metadata_website.prompty new file mode 100644 index 00000000..cf6ed80c --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_connector_metadata_website.prompty @@ -0,0 +1,31 @@ +--- +name: OpenAPI corporate website URL +description: Determine the corporate website URL for an API based on provided metadata. +authors: + - Dev Proxy +model: + api: chat +sample: + request: | + Default URL: https://example.com + Response: https://example.com +--- + +system: +You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the corporate website URL for the API. If the corporate website URL cannot be determined, respond with the default URL provided. + +API Metadata: +- Default URL: {{defaultUrl}} + +Rules you must follow: +- Do not output any explanations or additional text. +- The URL must be a valid, publicly accessible website. +- The URL must not contain placeholders or invalid characters. +- If no corporate website URL can be determined, return the default URL. + +Example: +Default URL: https://example.com +Response: https://example.com + +user: +Default URL: {{defaultUrl}} \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_description.prompty b/DevProxy/prompts/powerplatform_api_description.prompty new file mode 100644 index 00000000..f10ed02a --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_description.prompty @@ -0,0 +1,29 @@ +--- +name: OpenAPI API description +description: Generate a concise and descriptive summary for an OpenAPI API based on provided metadata. +authors: + - Dev Proxy +model: + api: chat +sample: + request: "Description: This API allows users to manage books and authors." +--- + +system: +You're an expert in OpenAPI and API documentation. Based on the following OpenAPI document metadata, generate a concise and descriptive summary for the API. Include the purpose of the API and the types of operations it supports. Respond with just the description. + +Rules: +- Must exist and be written in English. +- Must be free of grammatical and spelling errors. +- Should describe concisely the main purpose and value offered by your connector. +- Must be longer than 30 characters and shorter than 500 characters. +- Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). + +Example: +If the API is for managing books, you might respond with: 'Allows users to manage books, including operations to create, retrieve, update, and delete book records.' + +user: +OpenAPI Metadata: +- Description: {{description}} + +Now, generate the description for this API. \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_operation_description.prompty b/DevProxy/prompts/powerplatform_api_operation_description.prompty new file mode 100644 index 00000000..e2e3a592 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_operation_description.prompty @@ -0,0 +1,18 @@ +--- +name: OpenAPI operation description (sentence) +description: Generate a one-sentence description for an OpenAPI operation that ends in punctuation. +authors: + - Dev Proxy +model: + api: chat +sample: + request: "Request: GET https://api.contoso.com/books/{books-id}" +--- + +system: +You're an expert in OpenAPI. You help developers build great OpenAPI specs for use with LLMs. For the specified request, generate a one-sentence description that ends in punctuation. Respond with just the description. + +For example, for a request such as `GET https://api.contoso.com/books/{books-id}` you return `Get a book by ID.` + +user: +Request: {{request}} \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_operation_id.prompty b/DevProxy/prompts/powerplatform_api_operation_id.prompty new file mode 100644 index 00000000..468e2ef8 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_operation_id.prompty @@ -0,0 +1,36 @@ +--- +name: OpenAPI operationId generator +description: Generate an operationId for an OpenAPI operation based on HTTP method and URL. +authors: + - Dev Proxy +model: + api: chat +sample: + request: "Request: GET https://api.contoso.com/books/{books-id}/authors" +--- + +system: +Generate an operation ID for an OpenAPI specification based on the HTTP method and URL provided. Follow these rules: +- The operation ID should be in camelCase format. +- Start with a verb that matches the HTTP method (e.g., get, create, update, delete). +- Use descriptive words from the URL path. +- Replace path parameters (e.g., {userId}) with relevant nouns in singular form (e.g., User). +- Do not provide explanations or any other text; respond only with the operation ID. + +Examples: +Request: GET https://api.contoso.com/books/{books-id} +getBook + +Request: GET https://api.contoso.com/books/{books-id}/authors +getBookAuthors + +Request: GET https://api.contoso.com/books/{books-id}/authors/{authors-id} +getBookAuthor + +Request: POST https://api.contoso.com/books/{books-id}/authors +addBookAuthor + +user: +Request: {{request}} + +Now, generate the operation ID for this \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_parameter_description.prompty b/DevProxy/prompts/powerplatform_api_parameter_description.prompty new file mode 100644 index 00000000..467a355a --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_parameter_description.prompty @@ -0,0 +1,31 @@ +--- +name: OpenAPI parameter description +description: Generate a concise and descriptive summary for an OpenAPI parameter based on its metadata. +authors: + - Dev Proxy +model: + api: chat +sample: + request: | + Name: filter + Location: query +--- + +system: +You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. The description must adhere to the following rules: +- Must exist and be written in English. +- Must be a full, descriptive sentence, and end in punctuation. +- Must be free of grammatical and spelling errors. +- Must describe the purpose of the parameter and its role in the request. +- Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). + +Examples: +- For a query parameter named 'filter', return: 'Specifies a filter to narrow results.' +- For a path parameter named 'userId', return: 'Specifies the user ID to retrieve details.' + +user: +Parameter Metadata: +- Name: {{parameterName}} +- Location: {{location}} + +Now, generate the description for this parameter. \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_parameter_summary.prompty b/DevProxy/prompts/powerplatform_api_parameter_summary.prompty new file mode 100644 index 00000000..508f1ffe --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_parameter_summary.prompty @@ -0,0 +1,25 @@ +--- +name: OpenAPI parameter summary +description: Generate a concise summary for an OpenAPI parameter based on its metadata. +authors: + - Dev Proxy +model: + api: chat +sample: + request: | + Name: filter + Location: query +--- + +system: +You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. The summary must adhere to the following rules: +- Must exist and be written in English. +- Must be free of grammatical and spelling errors. +- Must be 80 characters or less. + +user: +Parameter Metadata: +- Name: {{parameterName}} +- Location: {{location}} + +Now, generate the summary for this parameter. \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_response_property_description.prompty b/DevProxy/prompts/powerplatform_api_response_property_description.prompty new file mode 100644 index 00000000..e8175f1b --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_response_property_description.prompty @@ -0,0 +1,38 @@ +--- +name: OpenAPI property description +description: Generate a concise, human-readable description for an OpenAPI property name. +authors: + - Dev Proxy +model: + api: chat +sample: + request: | + Property Name: user_email_address + response: The email address of the user who triggered the event. +--- + +system: +You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable description for the property. The description must: +- Be a full, descriptive sentence and end in punctuation. +- Be written in English. +- Be free of grammatical and spelling errors. +- Clearly explain the purpose or meaning of the property. +- Not repeat the property name verbatim if it contains underscores or is not human-friendly. +- Be suitable for use as a 'description' in OpenAPI schema properties. +- Only return the description, without any additional text or explanation. + +Examples: +Property Name: tenant_id +Description: The ID of the tenant this notification belongs to. + +Property Name: event_type +Description: The type of the event. + +Property Name: created_at +Description: The timestamp of when the event was generated. + +Property Name: user_email_address +Description: The email address of the user who triggered the event. + +user: +Property Name: {{propertyName}} \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_response_property_title.prompty b/DevProxy/prompts/powerplatform_api_response_property_title.prompty new file mode 100644 index 00000000..c6f20da1 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_response_property_title.prompty @@ -0,0 +1,35 @@ +--- +name: OpenAPI property title +description: Generate a concise, human-readable title for an OpenAPI property name. +authors: + - Dev Proxy +model: + api: chat +sample: + request: | + Property Name: user_email_address +--- + +system: +You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable title for the property. The title must: +- Be in Title Case (capitalize the first letter of each word). +- Be 2-5 words long. +- Not include underscores, dashes, or technical jargon. +- Not repeat the property name verbatim if it contains underscores or is not human-friendly. +- Be clear, descriptive, and suitable for use as a 'title' in OpenAPI schema properties. + +Examples: +Property Name: tenant_id +Title: Tenant ID + +Property Name: event_type +Title: Event Type + +Property Name: created_at +Title: Created At + +Property Name: user_email_address +Title: User Email Address + +user: +Property Name: {{propertyName}} \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_title.prompty b/DevProxy/prompts/powerplatform_api_title.prompty new file mode 100644 index 00000000..847beb71 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_title.prompty @@ -0,0 +1,30 @@ +--- +name: OpenAPI API title +description: Generate a concise and descriptive title for an OpenAPI API based on provided guidelines and a default title. +authors: + - Dev Proxy +model: + api: chat +sample: + request: "Default Title: Contoso Calendar API" +--- + +system: +You're an expert in OpenAPI and API documentation. Based on the following guidelines, generate a concise and descriptive title for the API. The title must meet the following requirements: + +- Must exist and be written in English. +- Must be unique and distinguishable from any existing connector and/or plugin title. +- Should be the name of the product or organization. +- Should follow existing naming patterns for certified connectors and/or plugins. For independent publishers, the connector name should follow the pattern: Connector Name (Independent Publisher). +- Can't be longer than 30 characters. +- Can't contain the words API, Connector, Copilot Studio, or any other Power Platform product names (for example, Power Apps). +- Can't end in a nonalphanumeric character, including carriage return, new line, or blank space. + +Examples: +- Good titles: Azure Sentinel, Office 365 Outlook +- Poor titles: Azure Sentinel's Power Apps Connector, Office 365 Outlook API + +user: +Default Title: {{defaultTitle}} + +Now, generate a title for this API. \ No newline at end of file From 2bc4dc9e3aed56cf050335209dbea39f2537e6b9 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 14:45:30 -0400 Subject: [PATCH 12/24] fix: Refactor OpenAPI contact information handling to allow null values and improve URL validation --- .../Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index 9092462b..85d808dd 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -96,9 +96,9 @@ private void SetContactInfo(OpenApiDocument openApiDoc) { openApiDoc.Info.Contact = new OpenApiContact { - Name = Configuration.Contact?.Name ?? "Your Name", - Url = Uri.TryCreate(Configuration.Contact?.Url, UriKind.Absolute, out var url) ? url : new Uri("https://www.yourwebsite.com"), - Email = Configuration.Contact?.Email ?? "your.email@yourdomain.com" + Name = Configuration.Contact?.Name, + Url = !string.IsNullOrWhiteSpace(Configuration.Contact?.Url) ? new Uri(Configuration.Contact.Url) : null, + Email = Configuration.Contact?.Email }; } From 1f37afd8b580945a219fdd57598706cee56d8717 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 14:46:09 -0400 Subject: [PATCH 13/24] fix: Update documentation to reflect the removal of the x-ms-generated-by extension from the OpenAPI document --- .../Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index 85d808dd..78d5e9a0 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -586,7 +586,7 @@ private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, } /// - /// Removes the x-ms-connector-metadata extension from the OpenAPI document if it exists. + /// Removes the x-ms-generated-by extension from the OpenAPI document if it exists. /// /// The OpenAPI document to process. private static void RemoveConnectorMetadataExtension(OpenApiDocument openApiDoc) From 3f3f38731d52ab03f39d39d71ad9f7a172978c95 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 14:49:28 -0400 Subject: [PATCH 14/24] fix: Refactor contact information handling in OpenAPI document to use new ToOpenApiContact method --- .../Generation/OpenApiSpecGeneratorPlugin.cs | 9 +++++++++ .../PowerPlatformOpenApiSpecGeneratorPlugin.cs | 16 +--------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 423a15be..7105e811 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -55,6 +55,15 @@ public class ContactConfig public string Name { get; set; } = "Your Name"; public string Url { get; set; } = "https://www.yourwebsite.com"; public string Email { get; set; } = "your.email@yourdomain.com"; + public OpenApiContact ToOpenApiContact() + { + return new OpenApiContact + { + Name = Name, + Url = !string.IsNullOrWhiteSpace(Url) ? new Uri(Url) : null, + Email = Email + }; + } } public class ConnectorMetadataConfig diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index 78d5e9a0..54c6744d 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -55,7 +55,7 @@ protected override OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri protected override void ProcessOpenApiDocument(OpenApiDocument openApiDoc) { ArgumentNullException.ThrowIfNull(openApiDoc); - SetContactInfo(openApiDoc); + openApiDoc.Info.Contact = Configuration.Contact?.ToOpenApiContact(); SetTitleAndDescription(openApiDoc); // Try to get the server URL from the OpenAPI document @@ -88,20 +88,6 @@ private void SetTitleAndDescription(OpenApiDocument openApiDoc) openApiDoc.Info.Description = description; } - /// - /// Sets the OpenApiContact in the Info area of the OpenApiDocument using configuration values. - /// - /// The OpenAPI document to process. - private void SetContactInfo(OpenApiDocument openApiDoc) - { - openApiDoc.Info.Contact = new OpenApiContact - { - Name = Configuration.Contact?.Name, - Url = !string.IsNullOrWhiteSpace(Configuration.Contact?.Url) ? new Uri(Configuration.Contact.Url) : null, - Email = Configuration.Contact?.Email - }; - } - /// /// Removes the x-ms-connector-metadata extension from the OpenAPI document if it exists /// and is empty. From 57cc4aff13510697fbe786b8ad9903366aa2d00d Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 15:00:06 -0400 Subject: [PATCH 15/24] fix: Update OpenApiSpecGeneratorPlugin and PowerPlatformOpenApiSpecGeneratorPlugin to use async methods for processing path items and documents --- .../Generation/OpenApiSpecGeneratorPlugin.cs | 11 ++++++----- .../PowerPlatformOpenApiSpecGeneratorPlugin.cs | 15 +++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 7105e811..9acbd0fc 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -156,7 +156,7 @@ request.Context.Session is null || request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), parametrizedPath ); - var processedPathItem = ProcessPathItem(pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); + var processedPathItem = await ProcessPathItemAsync(pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); AddOrMergePathItem(openApiDocs, processedPathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); } catch (Exception ex) @@ -170,7 +170,7 @@ request.Context.Session is null || foreach (var openApiDoc in openApiDocs) { // Allow derived plugins to post-process the OpenApiDocument (above the path level) - ProcessOpenApiDocument(openApiDoc); + await ProcessOpenApiDocumentAsync(openApiDoc); var server = openApiDoc.Servers.First(); var fileName = GetFileNameFromServerUrl(server.Url, Configuration.SpecFormat); @@ -219,19 +219,20 @@ request.Context.Session is null || /// The request URI. /// The parametrized path string. /// The processed OpenApiPathItem. - protected virtual OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + protected virtual Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { // By default, return the path item unchanged. Derived plugins can override to add/modify path-level data. - return pathItem; + return Task.FromResult(pathItem); } /// /// Allows derived plugins to post-process the OpenApiDocument before it is serialized and written to disk. /// /// The OpenApiDocument to process. - protected virtual void ProcessOpenApiDocument(OpenApiDocument openApiDoc) + protected virtual Task ProcessOpenApiDocumentAsync(OpenApiDocument openApiDoc) { // By default, do nothing. Derived plugins can override to add/modify document-level data. + return Task.CompletedTask; } private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index 54c6744d..e7a53eaa 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -36,23 +36,22 @@ IConfigurationSection pluginConfigurationSection /// The request URI for context. /// The parametrized path for the operation. /// The processed OpenAPI path item. - protected override OpenApiPathItem ProcessPathItem(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + protected override async Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { ArgumentNullException.ThrowIfNull(pathItem); ArgumentNullException.ThrowIfNull(requestUri); - // Synchronously invoke the async details processor - ProcessPathItemDetailsAsync(pathItem, requestUri, parametrizedPath).GetAwaiter().GetResult(); + await ProcessPathItemDetailsAsync(pathItem, requestUri, parametrizedPath); return pathItem; } /// /// Processes the OpenAPI document to set contact information, title, description, and connector metadata. - /// This method is called synchronously during the OpenAPI document processing. + /// This method is called asynchronously during the OpenAPI document processing. /// /// The OpenAPI document to process. /// Thrown if is null. - protected override void ProcessOpenApiDocument(OpenApiDocument openApiDoc) + protected override async Task ProcessOpenApiDocumentAsync(OpenApiDocument openApiDoc) { ArgumentNullException.ThrowIfNull(openApiDoc); openApiDoc.Info.Contact = Configuration.Contact?.ToOpenApiContact(); @@ -66,8 +65,8 @@ protected override void ProcessOpenApiDocument(OpenApiDocument openApiDoc) throw new InvalidOperationException("No server URL found in the OpenAPI document. Please ensure the document contains at least one server definition."); } - // Synchronously call the async metadata generator - var metadata = GenerateConnectorMetadataAsync(serverUrl).GetAwaiter().GetResult(); + // Asynchronously call the metadata generator + var metadata = await GenerateConnectorMetadataAsync(serverUrl); openApiDoc.Extensions["x-ms-connector-metadata"] = metadata; RemoveConnectorMetadataExtension(openApiDoc); } @@ -135,7 +134,7 @@ private async Task GetOpenApiTitleAsync(string defaultTitle) /// The parametrized path for the operation. private async Task ProcessPathItemDetailsAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { - var serverUrl = $"{requestUri.Scheme}://{requestUri.Host}{(requestUri.IsDefaultPort ? "" : ":" + requestUri.Port)}"; + var serverUrl = requestUri.GetLeftPart(UriPartial.Authority); foreach (var (method, operation) in pathItem.Operations) { // Update operationId From ab7a267195fe2182209539370542b4540cf78df9 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 15:05:22 -0400 Subject: [PATCH 16/24] fix: Update PowerPlatformOpenApiSpecGeneratorPlugin to use chat completion for operation summaries and add prompt file --- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 21 +++-------- ...owerplatform_api_operation_summary.prompty | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 DevProxy/prompts/powerplatform_api_operation_summary.prompty diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index e7a53eaa..d5e5753a 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -251,26 +251,13 @@ private async Task GetOperationIdAsync(string method, string serverUrl, /// The generated summary. private async Task GetOperationSummaryAsync(string method, string serverUrl, string parametrizedPath) { - var prompt = $@"You're an expert in OpenAPI. - You help developers build great OpenAPI specs for use with LLMs. - For the specified request, generate a concise, one-sentence summary that adheres to the following rules: - - Must exist and be written in English. - - Must be a phrase and cannot not end with punctuation. - - Must be free of grammatical and spelling errors. - - Must be 80 characters or less. - - Must contain only alphanumeric characters or parentheses. - - Must not include the words API, Connector, or any other Power Platform product names (for example, Power Apps). - - Respond with just the summary. - - For example: - - For a request such as `GET https://api.contoso.com/books/{{books-id}}`, return `Get a book by ID` - - For a request such as `POST https://api.contoso.com/books`, return `Create a new book` - - Request: {method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}"; ILanguageModelCompletionResponse? description = null; if (await _languageModelClient.IsEnabledAsync()) { - description = await _languageModelClient.GenerateCompletionAsync(prompt); + description = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_operation_summary", new() + { + { "request", @$"{method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}" } + }); } return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; } diff --git a/DevProxy/prompts/powerplatform_api_operation_summary.prompty b/DevProxy/prompts/powerplatform_api_operation_summary.prompty new file mode 100644 index 00000000..f2766e27 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_operation_summary.prompty @@ -0,0 +1,36 @@ +--- +name: OpenAPI operation summary +description: Generate a concise, one-sentence summary for an OpenAPI operation request. +authors: + - Dev Proxy +model: + api: chat +sample: + request: | + Request: GET https://api.contoso.com/books/{books-id} + Summary: Get a book by ID + + Request: POST https://api.contoso.com/books + Summary: Create a new book +--- + +system: +You're an expert in OpenAPI. You help developers build great OpenAPI specs for use with LLMs. For the specified request, generate a concise, one-sentence summary that adheres to the following rules: +- Must exist and be written in English. +- Must be a phrase and cannot end with punctuation. +- Must be free of grammatical and spelling errors. +- Must be 80 characters or less. +- Must contain only alphanumeric characters or parentheses. +- Must not include the words API, Connector, or any other Power Platform product names (for example, Power Apps). +- Respond with just the summary. + +Examples: +Request: GET https://api.contoso.com/books/{books-id} +Summary: Get a book by ID + +Request: POST https://api.contoso.com/books +Summary: Create a new book + +user: +Request: {{request}} +Summary: \ No newline at end of file From 5f6ecadb3a0d2582546c2f0272c38449ea522b04 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 15:09:11 -0400 Subject: [PATCH 17/24] fix: Update launch configuration to include config file argument and add new powerplat.json configuration file --- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index d5e5753a..972983e4 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -158,20 +158,26 @@ private async Task ProcessPathItemDetailsAsync(OpenApiPathItem pathItem, Uri req } // Process responses - if (operation.Responses != null) + if (operation.Responses is null) { - foreach (var response in operation.Responses.Values) + continue; + } + + foreach (var response in operation.Responses.Values) + { + if (response.Content is null) { - if (response.Content != null) + continue; + } + + foreach (var mediaType in response.Content.Values) + { + if (mediaType.Schema is null) { - foreach (var mediaType in response.Content.Values) - { - if (mediaType.Schema != null) - { - await ProcessSchemaPropertiesAsync(mediaType.Schema); - } - } + continue; } + + await ProcessSchemaPropertiesAsync(mediaType.Schema); } } } From a964d37b04717e0134600870503212b15b591445 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 15:11:42 -0400 Subject: [PATCH 18/24] fix: Update RemoveResponseHeadersIfDisabled method to handle null pathItem and improve response header clearing logic --- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index 972983e4..c943db40 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -213,17 +213,21 @@ private async Task ProcessSchemaPropertiesAsync(OpenApiSchema schema) /// The OpenAPI path item to process. private void RemoveResponseHeadersIfDisabled(OpenApiPathItem pathItem) { - if (!Configuration.IncludeResponseHeaders && pathItem != null) + if (Configuration.IncludeResponseHeaders || pathItem is null) { - foreach (var operation in pathItem.Operations.Values) + return; + } + + foreach (var operation in pathItem.Operations.Values) + { + if (operation.Responses is null) { - if (operation.Responses != null) - { - foreach (var response in operation.Responses.Values) - { - response.Headers?.Clear(); - } - } + continue; + } + + foreach (var response in operation.Responses.Values) + { + response.Headers?.Clear(); } } } From 27037ea5cb576ac4c882c375aeac25b4f14439e7 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 15:22:23 -0400 Subject: [PATCH 19/24] fix: Rearrange properties in PowerPlatformOpenApiSpecGeneratorPlugin schema for clarity and consistency --- ...formopenapispecgeneratorplugin.schema.json | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json index b3787b22..376b73dd 100644 --- a/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json +++ b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json @@ -7,67 +7,67 @@ "type": "string", "description": "The JSON schema reference for validation." }, - "includeOptionsRequests": { - "type": "boolean", - "description": "Determines whether to include OPTIONS requests in the generated OpenAPI spec. Default: false." - }, - "specFormat": { - "type": "string", - "enum": [ - "Json", - "Yaml" - ], - "description": "Specifies the format of the generated OpenAPI spec. Allowed values: 'Json' or 'Yaml'. Default: 'Json'." - }, - "includeResponseHeaders": { - "type": "boolean", - "description": "Determines whether to include request headers in the generated OpenAPI spec. Default: false." - }, - "contact": { + "connectorMetadata": { "type": "object", - "description": "Contact information for the API.", + "description": "Optional metadata for the connector.", "properties": { - "name": { - "type": "string", - "description": "The name of the contact person or organization." + "categories": { + "type": "array", + "description": "An array of categories for the API.", + "items": { + "type": "string", + "description": "A category for the API." + } }, - "url": { + "privacyPolicy": { "type": "string", "format": "uri", - "description": "The URL pointing to the contact information." + "description": "The privacy policy URL for the API." }, - "email": { + "website": { "type": "string", - "format": "email", - "description": "The email address of the contact person or organization." + "format": "uri", + "description": "The corporate website URL for the API." } - }, + }, "additionalProperties": false }, - "connectorMetadata": { + "contact": { "type": "object", - "description": "Optional metadata for the connector.", + "description": "Contact information for the API.", "properties": { - "website": { + "email": { "type": "string", - "format": "uri", - "description": "The corporate website URL for the API." + "format": "email", + "description": "The email address of the contact person or organization." }, - "privacyPolicy": { + "name": { "type": "string", - "format": "uri", - "description": "The privacy policy URL for the API." + "description": "The name of the contact person or organization." }, - "categories": { - "type": "array", - "description": "An array of categories for the API.", - "items": { - "type": "string", - "description": "A category for the API." - } + "url": { + "type": "string", + "format": "uri", + "description": "The URL pointing to the contact information." } }, "additionalProperties": false + }, + "includeOptionsRequests": { + "type": "boolean", + "description": "Determines whether to include OPTIONS requests in the generated OpenAPI spec. Default: false." + }, + "includeResponseHeaders": { + "type": "boolean", + "description": "Determines whether to include request headers in the generated OpenAPI spec. Default: false." + }, + "specFormat": { + "type": "string", + "enum": [ + "Json", + "Yaml" + ], + "description": "Specifies the format of the generated OpenAPI spec. Allowed values: 'Json' or 'Yaml'. Default: 'Json'." } }, "additionalProperties": false From e0b64a49f5eefbd2a96a6fa163401811254f45b9 Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 15:30:55 -0400 Subject: [PATCH 20/24] fix: Add default values to properties in PowerPlatformOpenApiSpecGeneratorPlugin schema for clarity --- ...formopenapispecgeneratorplugin.schema.json | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json index 376b73dd..e803056d 100644 --- a/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json +++ b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json @@ -17,7 +17,8 @@ "items": { "type": "string", "description": "A category for the API." - } + }, + "default": ["Data"] }, "privacyPolicy": { "type": "string", @@ -39,27 +40,32 @@ "email": { "type": "string", "format": "email", - "description": "The email address of the contact person or organization." + "description": "The email address of the contact person or organization.", + "default": "your.email@yourdomain.com" }, "name": { "type": "string", - "description": "The name of the contact person or organization." + "description": "The name of the contact person or organization.", + "default": "Your Name" }, "url": { "type": "string", "format": "uri", - "description": "The URL pointing to the contact information." + "description": "The URL pointing to the contact information.", + "default": "https://www.yourwebsite.com" } }, "additionalProperties": false }, "includeOptionsRequests": { "type": "boolean", - "description": "Determines whether to include OPTIONS requests in the generated OpenAPI spec. Default: false." + "description": "Determines whether to include OPTIONS requests in the generated OpenAPI spec.", + "default": false }, "includeResponseHeaders": { "type": "boolean", - "description": "Determines whether to include request headers in the generated OpenAPI spec. Default: false." + "description": "Determines whether to include request headers in the generated OpenAPI spec.", + "default": false }, "specFormat": { "type": "string", @@ -67,7 +73,8 @@ "Json", "Yaml" ], - "description": "Specifies the format of the generated OpenAPI spec. Allowed values: 'Json' or 'Yaml'. Default: 'Json'." + "description": "Specifies the format of the generated OpenAPI spec. Allowed values: 'Json' or 'Yaml'", + "default": "Json" } }, "additionalProperties": false From 7b5df250044040987814517f37348902270fd1dd Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 15:33:55 -0400 Subject: [PATCH 21/24] fix: Refactor ProcessPathItemAsync method to improve async handling and remove unnecessary return value --- DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs | 8 ++++---- .../Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 9acbd0fc..95595af3 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -156,8 +156,8 @@ request.Context.Session is null || request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), parametrizedPath ); - var processedPathItem = await ProcessPathItemAsync(pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); - AddOrMergePathItem(openApiDocs, processedPathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); + await ProcessPathItemAsync(pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); + AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); } catch (Exception ex) { @@ -219,10 +219,10 @@ request.Context.Session is null || /// The request URI. /// The parametrized path string. /// The processed OpenApiPathItem. - protected virtual Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + protected virtual Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { // By default, return the path item unchanged. Derived plugins can override to add/modify path-level data. - return Task.FromResult(pathItem); + return Task.CompletedTask; } /// diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index c943db40..63a4931f 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -36,13 +36,12 @@ IConfigurationSection pluginConfigurationSection /// The request URI for context. /// The parametrized path for the operation. /// The processed OpenAPI path item. - protected override async Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + protected override async Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { ArgumentNullException.ThrowIfNull(pathItem); ArgumentNullException.ThrowIfNull(requestUri); await ProcessPathItemDetailsAsync(pathItem, requestUri, parametrizedPath); - return pathItem; } /// From 032ae050e10428e68dfbe4d366fb1c0f89bdfaea Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 16:03:04 -0400 Subject: [PATCH 22/24] fix: Update GetOperationIdAsync and GetOperationDescriptionAsync methods to use prompt file parameter and improve method visibility --- .../Generation/OpenApiSpecGeneratorPlugin.cs | 13 ++++-- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 42 +++++++------------ 2 files changed, 23 insertions(+), 32 deletions(-) diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 95595af3..2ca74d44 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -235,12 +235,15 @@ protected virtual Task ProcessOpenApiDocumentAsync(OpenApiDocument openApiDoc) return Task.CompletedTask; } - private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) + protected virtual async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath, string promptyFile = "api_operation_id") { + ArgumentException.ThrowIfNullOrEmpty(method); + ArgumentException.ThrowIfNullOrEmpty(parametrizedPath); + ILanguageModelCompletionResponse? id = null; if (await languageModelClient.IsEnabledAsync()) { - id = await languageModelClient.GenerateChatCompletionAsync("api_operation_id", new() + id = await languageModelClient.GenerateChatCompletionAsync(promptyFile, new() { { "request", $"{method.ToUpperInvariant()} {serverUrl}{parametrizedPath}" } }); @@ -248,12 +251,14 @@ private async Task GetOperationIdAsync(string method, string serverUrl, return id?.Response ?? $"{method}{parametrizedPath.Replace('/', '.')}"; } - private async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath) + protected virtual async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath, string promptyFile = "api_operation_description") { + ArgumentException.ThrowIfNullOrEmpty(method); + ILanguageModelCompletionResponse? description = null; if (await languageModelClient.IsEnabledAsync()) { - description = await languageModelClient.GenerateChatCompletionAsync("api_operation_description", new() + description = await languageModelClient.GenerateChatCompletionAsync(promptyFile, new() { { "request", $"{method.ToUpperInvariant()} {serverUrl}{parametrizedPath}" } }); diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index 63a4931f..fb658633 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -237,55 +237,41 @@ private void RemoveResponseHeadersIfDisabled(OpenApiPathItem pathItem) /// The HTTP method. /// The server URL. /// The parametrized path. - /// The generated operationId. - private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) + /// The prompt file to use for LLM generation. + /// The generated operation id. + protected override Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath, string promptyFile = "powerplatform_api_operation_id") { - ILanguageModelCompletionResponse? id = null; - if (await _languageModelClient.IsEnabledAsync()) - { - id = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_operation_id", new() - { - { "request", $"{method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}" } - }); - } - return id?.Response?.Trim() ?? $"{method}{parametrizedPath.Replace('/', '.')}"; + return base.GetOperationIdAsync(method, serverUrl, parametrizedPath, promptyFile); } /// - /// Generates a summary for an OpenAPI operation using LLM or fallback logic. + /// Generates an operationId for an OpenAPI operation using LLM or fallback logic. /// /// The HTTP method. /// The server URL. /// The parametrized path. - /// The generated summary. - private async Task GetOperationSummaryAsync(string method, string serverUrl, string parametrizedPath) + /// The prompt file to use for LLM generation. + /// The generated operation description. + protected override Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath, string promptyFile = "powerplatform_api_operation_description") { - ILanguageModelCompletionResponse? description = null; - if (await _languageModelClient.IsEnabledAsync()) - { - description = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_operation_summary", new() - { - { "request", @$"{method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}" } - }); - } - return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; + return base.GetOperationDescriptionAsync(method, serverUrl, parametrizedPath, promptyFile); } /// - /// Generates a description for an OpenAPI operation using LLM or fallback logic. + /// Generates a summary for an OpenAPI operation using LLM or fallback logic. /// /// The HTTP method. /// The server URL. /// The parametrized path. - /// The generated description. - private async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath) + /// The generated summary. + private async Task GetOperationSummaryAsync(string method, string serverUrl, string parametrizedPath) { ILanguageModelCompletionResponse? description = null; if (await _languageModelClient.IsEnabledAsync()) { - description = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_operation_description", new() + description = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_operation_summary", new() { - { "request", $"{method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}" } + { "request", @$"{method.ToUpper(CultureInfo.InvariantCulture)} {serverUrl}{parametrizedPath}" } }); } return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; From 264d676773eef49a077bece1394d5f4e8aa6276f Mon Sep 17 00:00:00 2001 From: ricwilson Date: Tue, 24 Jun 2025 16:25:31 -0400 Subject: [PATCH 23/24] fix: Remove unnecessary line from PowerPlatformOpenApiSpecGeneratorPlugin class --- .../Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index fb658633..ee699f31 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -564,5 +564,4 @@ private static void RemoveConnectorMetadataExtension(OpenApiDocument openApiDoc) _ = openApiDoc.Extensions.Remove("x-ms-generated-by"); } } - } From c2ce935caeb351fc101ac0c80306a29ef0ed436e Mon Sep 17 00:00:00 2001 From: ricwilson Date: Thu, 26 Jun 2025 11:03:46 -0400 Subject: [PATCH 24/24] Refactor OpenApiSpecGeneratorPlugin and PowerPlatformOpenApiSpecGeneratorPlugin for improved operation ID and description generation; streamline prompt files for parameter and response handling; enhance API title generation logic; remove unused prompt files. --- .../Generation/OpenApiSpecGeneratorPlugin.cs | 34 ++--- ...PowerPlatformOpenApiSpecGeneratorPlugin.cs | 118 ++++++++---------- DevProxy/DevProxy.csproj | 3 + ..._api_connector_metadata_categories.prompty | 43 ++++--- ..._connector_metadata_privacy_policy.prompty | 30 ----- ...orm_api_connector_metadata_website.prompty | 31 ----- .../powerplatform_api_description.prompty | 69 ++++++++-- ...platform_api_operation_description.prompty | 46 ++++++- .../powerplatform_api_operation_id.prompty | 63 +++++++--- ...owerplatform_api_operation_summary.prompty | 55 +++++--- ...platform_api_parameter_description.prompty | 58 +++++++-- ...owerplatform_api_parameter_summary.prompty | 51 ++++++-- ..._api_response_property_description.prompty | 58 ++++++--- ...atform_api_response_property_title.prompty | 46 ++++--- .../prompts/powerplatform_api_title.prompty | 64 +++++++--- 15 files changed, 485 insertions(+), 284 deletions(-) delete mode 100644 DevProxy/prompts/powerplatform_api_connector_metadata_privacy_policy.prompty delete mode 100644 DevProxy/prompts/powerplatform_api_connector_metadata_website.prompty diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 2ca74d44..10811b82 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -120,7 +120,6 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e) var openApiDocs = new List(); - foreach (var request in e.RequestLogs) { if (request.MessageType != MessageType.InterceptedResponse || @@ -145,17 +144,6 @@ request.Context.Session is null || { var pathItem = GetOpenApiPathItem(request.Context.Session); var parametrizedPath = ParametrizePath(pathItem, request.Context.Session.HttpClient.Request.RequestUri); - var operationInfo = pathItem.Operations.First(); - operationInfo.Value.OperationId = await GetOperationIdAsync( - operationInfo.Key.ToString(), - request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), - parametrizedPath - ); - operationInfo.Value.Description = await GetOperationDescriptionAsync( - operationInfo.Key.ToString(), - request.Context.Session.HttpClient.Request.RequestUri.GetLeftPart(UriPartial.Authority), - parametrizedPath - ); await ProcessPathItemAsync(pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); AddOrMergePathItem(openApiDocs, pathItem, request.Context.Session.HttpClient.Request.RequestUri, parametrizedPath); } @@ -219,10 +207,22 @@ request.Context.Session is null || /// The request URI. /// The parametrized path string. /// The processed OpenApiPathItem. - protected virtual Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + protected virtual async Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { - // By default, return the path item unchanged. Derived plugins can override to add/modify path-level data. - return Task.CompletedTask; + ArgumentNullException.ThrowIfNull(pathItem); + ArgumentNullException.ThrowIfNull(requestUri); + + var operationInfo = pathItem.Operations.First(); + operationInfo.Value.OperationId = await GetOperationIdAsync( + operationInfo.Key.ToString(), + requestUri.GetLeftPart(UriPartial.Authority), + parametrizedPath + ); + operationInfo.Value.Description = await GetOperationDescriptionAsync( + operationInfo.Key.ToString(), + requestUri.GetLeftPart(UriPartial.Authority), + parametrizedPath + ); } /// @@ -248,7 +248,7 @@ protected virtual async Task GetOperationIdAsync(string method, string s { "request", $"{method.ToUpperInvariant()} {serverUrl}{parametrizedPath}" } }); } - return id?.Response ?? $"{method}{parametrizedPath.Replace('/', '.')}"; + return id?.Response?.Trim() ?? $"{method}{parametrizedPath.Replace('/', '.')}"; } protected virtual async Task GetOperationDescriptionAsync(string method, string serverUrl, string parametrizedPath, string promptyFile = "api_operation_description") @@ -263,7 +263,7 @@ protected virtual async Task GetOperationDescriptionAsync(string method, { "request", $"{method.ToUpperInvariant()} {serverUrl}{parametrizedPath}" } }); } - return description?.Response ?? $"{method} {parametrizedPath}"; + return description?.Response?.Trim() ?? $"{method} {parametrizedPath}"; } /** diff --git a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs index ee699f31..8baec480 100644 --- a/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -36,12 +36,15 @@ IConfigurationSection pluginConfigurationSection /// The request URI for context. /// The parametrized path for the operation. /// The processed OpenAPI path item. - protected override async Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + /// Thrown if or is null. + protected override async Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) { ArgumentNullException.ThrowIfNull(pathItem); ArgumentNullException.ThrowIfNull(requestUri); await ProcessPathItemDetailsAsync(pathItem, requestUri, parametrizedPath); + + return Task.CompletedTask; } /// @@ -54,18 +57,16 @@ protected override async Task ProcessOpenApiDocumentAsync(OpenApiDocument openAp { ArgumentNullException.ThrowIfNull(openApiDoc); openApiDoc.Info.Contact = Configuration.Contact?.ToOpenApiContact(); - SetTitleAndDescription(openApiDoc); // Try to get the server URL from the OpenAPI document var serverUrl = openApiDoc.Servers?.FirstOrDefault()?.Url; - if (string.IsNullOrWhiteSpace(serverUrl)) { throw new InvalidOperationException("No server URL found in the OpenAPI document. Please ensure the document contains at least one server definition."); } - // Asynchronously call the metadata generator - var metadata = await GenerateConnectorMetadataAsync(serverUrl); + var (apiDescription, operationDescriptions) = await SetTitleAndDescription(openApiDoc, serverUrl); + var metadata = await GenerateConnectorMetadataAsync(serverUrl, apiDescription, operationDescriptions); openApiDoc.Extensions["x-ms-connector-metadata"] = metadata; RemoveConnectorMetadataExtension(openApiDoc); } @@ -74,16 +75,29 @@ protected override async Task ProcessOpenApiDocumentAsync(OpenApiDocument openAp /// Sets the OpenApi title and description in the Info area of the OpenApiDocument using LLM-generated values. /// /// The OpenAPI document to process. - private void SetTitleAndDescription(OpenApiDocument openApiDoc) + /// The server URL to use as a fallback for title and description. + /// A tuple containing the API description and a list of operation descriptions. + private async Task<(string apiDescription, string operationDescriptions)> SetTitleAndDescription(OpenApiDocument openApiDoc, string serverUrl) { - // Synchronously call the async title/description generators - var defaultTitle = openApiDoc.Info?.Title ?? "API"; - var defaultDescription = openApiDoc.Info?.Description ?? "API description."; - var title = GetOpenApiTitleAsync(defaultTitle).GetAwaiter().GetResult(); - var description = GetOpenApiDescriptionAsync(defaultDescription).GetAwaiter().GetResult(); + var defaultTitle = openApiDoc.Info?.Title ?? serverUrl; + var defaultDescription = openApiDoc.Info?.Description ?? serverUrl; + var operationDescriptions = string.Join( + Environment.NewLine, + openApiDoc.Paths? + .SelectMany(p => p.Value.Operations.Values) + .Select(op => op.Description) + .Where(desc => !string.IsNullOrWhiteSpace(desc)) + .Distinct() + .Select(d => $"- {d}") ?? [] + ); + + var title = await GetOpenApiTitleAsync(defaultTitle, operationDescriptions); + var description = await GetOpenApiDescriptionAsync(defaultDescription, operationDescriptions); openApiDoc.Info ??= new OpenApiInfo(); openApiDoc.Info.Title = title; openApiDoc.Info.Description = description; + + return (description, operationDescriptions); } /// @@ -91,7 +105,9 @@ private void SetTitleAndDescription(OpenApiDocument openApiDoc) /// and is empty. /// /// The OpenAPI document to process. - private async Task GetOpenApiDescriptionAsync(string defaultDescription) + /// Operation descriptions to use for generating the OpenAPI description. + /// The OpenAPI description generated by LLM or the default description. + private async Task GetOpenApiDescriptionAsync(string defaultDescription, string operationDescriptions) { ILanguageModelCompletionResponse? description = null; @@ -99,7 +115,8 @@ private async Task GetOpenApiDescriptionAsync(string defaultDescription) { description = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_description", new() { - { "description", defaultDescription } + { "defaultDescription", defaultDescription }, + { "operationDescriptions", operationDescriptions } }); } @@ -110,14 +127,17 @@ private async Task GetOpenApiDescriptionAsync(string defaultDescription) /// Generates a concise and descriptive title for the OpenAPI document using LLM or fallback logic. /// /// The default title to use if LLM generation fails. - private async Task GetOpenApiTitleAsync(string defaultTitle) + /// A list of operation descriptions for context. + /// The generated title. + private async Task GetOpenApiTitleAsync(string defaultTitle, string operationDescriptions) { ILanguageModelCompletionResponse? title = null; if (await _languageModelClient.IsEnabledAsync()) { title = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_title", new() { - { "defaultTitle", defaultTitle } + { "defaultTitle", defaultTitle }, + { "operationDescriptions", operationDescriptions } }); } @@ -448,11 +468,13 @@ private static string GetResponsePropertyDescriptionFallback(string propertyName /// Generates the connector metadata OpenAPI extension array using configuration and LLM. /// /// The server URL for context. + /// The API description for context. + /// A list of operation descriptions for context. /// An containing connector metadata. - private async Task GenerateConnectorMetadataAsync(string serverUrl) + private async Task GenerateConnectorMetadataAsync(string serverUrl, string apiDescription, string operationDescriptions) { - var website = Configuration.ConnectorMetadata?.Website ?? await GetConnectorMetadataWebsiteUrlAsync(serverUrl); - var privacyPolicy = Configuration.ConnectorMetadata?.PrivacyPolicy ?? await GetConnectorMetadataPrivacyPolicyUrlAsync(serverUrl); + var website = Configuration.ConnectorMetadata?.Website ?? serverUrl; + var privacyPolicy = Configuration.ConnectorMetadata?.PrivacyPolicy ?? serverUrl; string categories; var categoriesList = Configuration.ConnectorMetadata?.Categories; @@ -462,7 +484,7 @@ private async Task GenerateConnectorMetadataAsync(string serverUrl } else { - categories = await GetConnectorMetadataCategoriesAsync(serverUrl, "Data"); + categories = await GetConnectorMetadataCategoriesAsync(serverUrl, apiDescription, operationDescriptions); } var metadataArray = new OpenApiArray @@ -486,55 +508,15 @@ private async Task GenerateConnectorMetadataAsync(string serverUrl return metadataArray; } - /// - /// Generates the website URL for connector metadata using LLM or configuration. - /// - /// The default URL to use if LLM fails. - /// The website URL. - private async Task GetConnectorMetadataWebsiteUrlAsync(string defaultUrl) - { - ILanguageModelCompletionResponse? response = null; - - if (await _languageModelClient.IsEnabledAsync()) - { - response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_connector_metadata_website", new() - { - { "defaultUrl", defaultUrl } - }); - } - - // Fallback to the default URL if the language model fails or returns no response - return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; - } - - /// - /// Generates the privacy policy URL for connector metadata using LLM or configuration. - /// - /// The default URL to use if LLM fails. - /// The privacy policy URL. - private async Task GetConnectorMetadataPrivacyPolicyUrlAsync(string defaultUrl) - { - ILanguageModelCompletionResponse? response = null; - - if (await _languageModelClient.IsEnabledAsync()) - { - response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_connector_metadata_privacy_policy", new() - { - { "defaultUrl", defaultUrl } - }); - } - - // Fallback to the default URL if the language model fails or returns no response - return !string.IsNullOrWhiteSpace(response?.Response) ? response.Response.Trim() : defaultUrl; - } - /// /// Generates the categories for connector metadata using LLM or configuration. /// /// The server URL for context. - /// The default categories to use if LLM fails. - /// The categories string. - private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, string defaultCategories) + /// The API description for context. + /// A list of operation descriptions for context. + /// A string containing the categories for the connector metadata. + /// Thrown if the language model is not enabled and + private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, string apiDescription, string operationDescriptions) { ILanguageModelCompletionResponse? response = null; @@ -542,14 +524,16 @@ private async Task GetConnectorMetadataCategoriesAsync(string serverUrl, { response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_connector_metadata_categories", new() { - { "serverUrl", serverUrl } + { "serverUrl", serverUrl }, + { "apiDescription", apiDescription }, + { "operationDescriptions", operationDescriptions } }); } // If the response is 'None' or empty, return the default categories return !string.IsNullOrWhiteSpace(response?.Response) && response.Response.Trim() != "None" - ? response.Response - : defaultCategories; + ? response.Response.Trim() + : "Data"; } /// diff --git a/DevProxy/DevProxy.csproj b/DevProxy/DevProxy.csproj index e6c977a7..cbeeb3cf 100644 --- a/DevProxy/DevProxy.csproj +++ b/DevProxy/DevProxy.csproj @@ -82,6 +82,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/DevProxy/prompts/powerplatform_api_connector_metadata_categories.prompty b/DevProxy/prompts/powerplatform_api_connector_metadata_categories.prompty index d92e1efc..a7763a69 100644 --- a/DevProxy/prompts/powerplatform_api_connector_metadata_categories.prompty +++ b/DevProxy/prompts/powerplatform_api_connector_metadata_categories.prompty @@ -1,6 +1,6 @@ --- -name: OpenAPI allowed categories -description: Determine the most appropriate categories for an API from the Microsoft Power Platform allowed list. +name: Power Platform OpenAPI Categories +description: Classify the API into one or more Microsoft Power Platform allowed categories based on the API metadata and server URL. authors: - Dev Proxy model: @@ -8,14 +8,20 @@ model: sample: request: | Server URL: https://api.example.com - Response: Data + API Description: A service that provides document collaboration features + Operation Descriptions: + - Share a document with another user. + - Retrieve a list of collaborators. + - Update document permissions. + Response: Collaboration, Content and Files + response: | + Collaboration, Content and Files --- system: -You're an expert in OpenAPI and API documentation. Based on the following API metadata and the server URL, determine the most appropriate categories for the API from the allowed list of categories. If you cannot determine appropriate categories, respond with 'None'. +You are an expert in OpenAPI and Microsoft Power Platform custom connectors. Your task is to classify an API based on its metadata and purpose using only the categories allowed by Power Platform. -API Metadata: -- Server URL: {{serverUrl}} +These categories are used in the Power Platform custom connector metadata field `x-ms-connector-metadata.categories`. Allowed Categories: - AI @@ -39,15 +45,24 @@ Allowed Categories: - Website Rules you must follow: -- Do not output any explanations or additional text. -- The categories must be from the allowed list. -- The categories must be relevant to the API's functionality and purpose. -- The categories should be in a comma-separated format. -- If you cannot determine appropriate categories, respond with 'None'. +- Only return categories from the allowed list. +- Choose categories relevant to the API's core functionality. +- Return a comma-separated list of categories, no more than 3. +- If no appropriate category can be confidently determined, return `None`. +- Do not include explanations or additional text. Example: -Server URL: https://api.example.com -Response: Data +Server URL: https://api.example.com +API Description: A service that provides document collaboration features +Operation Descriptions: +- Share a document with another user. +- Retrieve a list of collaborators. +- Update document permissions. +Response: Collaboration, Content and Files user: -Server URL: {{serverUrl}} \ No newline at end of file +Server URL: {{serverUrl}} +API Description: {{apiDescription}} +Operation Descriptions: +{{operationDescriptions}} +Response: \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_connector_metadata_privacy_policy.prompty b/DevProxy/prompts/powerplatform_api_connector_metadata_privacy_policy.prompty deleted file mode 100644 index a6237a06..00000000 --- a/DevProxy/prompts/powerplatform_api_connector_metadata_privacy_policy.prompty +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: OpenAPI privacy policy URL -description: Determine the privacy policy URL for an API based on provided metadata. -authors: - - Dev Proxy -model: - api: chat -sample: - request: | - Default URL: https://example.com/privacy - Response: https://example.com/privacy ---- - -system: -You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the privacy policy URL for the corporate website or API. If the privacy policy URL cannot be determined, respond with the default URL provided. - -API Metadata: -- Default URL: {{defaultUrl}} - -Rules you must follow: -- Do not output any explanations or additional text. -- The URL must be a valid, publicly accessible website. -- The URL must not contain placeholders or invalid characters. -- If no privacy policy URL can be determined, return the default URL. - -Example: -Response: https://example.com/privacy - -user: -Default URL: {{defaultUrl}} \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_connector_metadata_website.prompty b/DevProxy/prompts/powerplatform_api_connector_metadata_website.prompty deleted file mode 100644 index cf6ed80c..00000000 --- a/DevProxy/prompts/powerplatform_api_connector_metadata_website.prompty +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: OpenAPI corporate website URL -description: Determine the corporate website URL for an API based on provided metadata. -authors: - - Dev Proxy -model: - api: chat -sample: - request: | - Default URL: https://example.com - Response: https://example.com ---- - -system: -You're an expert in OpenAPI and API documentation. Based on the following API metadata, determine the corporate website URL for the API. If the corporate website URL cannot be determined, respond with the default URL provided. - -API Metadata: -- Default URL: {{defaultUrl}} - -Rules you must follow: -- Do not output any explanations or additional text. -- The URL must be a valid, publicly accessible website. -- The URL must not contain placeholders or invalid characters. -- If no corporate website URL can be determined, return the default URL. - -Example: -Default URL: https://example.com -Response: https://example.com - -user: -Default URL: {{defaultUrl}} \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_description.prompty b/DevProxy/prompts/powerplatform_api_description.prompty index f10ed02a..74c9d263 100644 --- a/DevProxy/prompts/powerplatform_api_description.prompty +++ b/DevProxy/prompts/powerplatform_api_description.prompty @@ -1,29 +1,72 @@ --- -name: OpenAPI API description -description: Generate a concise and descriptive summary for an OpenAPI API based on provided metadata. +name: Power Platform OpenAPI API Description With Operations +description: Generate a descriptive summary for an OpenAPI API using metadata and sample operations. authors: - Dev Proxy model: api: chat sample: - request: "Description: This API allows users to manage books and authors." + request: | + OpenAPI Metadata: + - Info Description: This API manages books and authors. + + Sample Operation Descriptions: + - Get a list of books. + - Retrieve details of a specific author. + - Create a new book record. + - Update an existing author's information. + - Delete a book by ID. + + Response: Allows users to manage books and authors, including operations to list, create, retrieve, update, and delete records. + response: | + Allows users to manage books and authors, including operations to list, create, retrieve, update, and delete records. --- system: -You're an expert in OpenAPI and API documentation. Based on the following OpenAPI document metadata, generate a concise and descriptive summary for the API. Include the purpose of the API and the types of operations it supports. Respond with just the description. +You are an expert in OpenAPI and Microsoft Power Platform connector documentation. + +Your task is to generate a concise but informative API-level `description` based on: +- The high-level API purpose (from `info.description`) +- A list of operation descriptions showing what the API does + +This description will be used in the `info.description` field of the OpenAPI document. It should: +- Clearly state the API’s **overall purpose** +- Summarize **what types of operations** are supported +- Mention the **primary entities** or domains involved Rules: -- Must exist and be written in English. -- Must be free of grammatical and spelling errors. -- Should describe concisely the main purpose and value offered by your connector. -- Must be longer than 30 characters and shorter than 500 characters. -- Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). +- Must be a **complete sentence or two**, ending in punctuation. +- Written in **plain English**, professionally worded. +- Between **30 and 500 characters**. +- Do not include Power Platform product names (e.g., Power Apps, Copilot). +- Do not list individual operations verbatim — summarize them. +- Return only the description — no extra text or markdown. -Example: -If the API is for managing books, you might respond with: 'Allows users to manage books, including operations to create, retrieve, update, and delete book records.' +Examples: + +OpenAPI Metadata: +- Info Description: This API manages books and authors. +- Operation Descriptions: + - Get a list of books. + - Retrieve details of a specific author. + - Create a new book record. + - Update an existing author's information. + - Delete a book by ID. +Response: Allows users to manage books and authors, including operations to list, create, retrieve, update, and delete records. + +OpenAPI Metadata: +- Info Description: Handles product inventory and stock updates. +- Operation Descriptions: + - Retrieve inventory for a product. + - Add stock to inventory. + - Update stock counts. + - Remove stock from a location. +Response: Provides endpoints for managing inventory, including tracking stock levels and updating quantities across products and locations. user: OpenAPI Metadata: -- Description: {{description}} +- Info Description: {{defaultDescription}} -Now, generate the description for this API. \ No newline at end of file +Sample Operation Descriptions: +{{operationDescriptions}} +Response: \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_operation_description.prompty b/DevProxy/prompts/powerplatform_api_operation_description.prompty index e2e3a592..9711351b 100644 --- a/DevProxy/prompts/powerplatform_api_operation_description.prompty +++ b/DevProxy/prompts/powerplatform_api_operation_description.prompty @@ -1,18 +1,52 @@ --- -name: OpenAPI operation description (sentence) -description: Generate a one-sentence description for an OpenAPI operation that ends in punctuation. +name: Power Platform OpenAPI Operation Description (Sentence) +description: Generate a one-sentence description for an OpenAPI operation based on the HTTP method and request URL. authors: - Dev Proxy model: api: chat sample: - request: "Request: GET https://api.contoso.com/books/{books-id}" + request: | + Request: GET https://api.contoso.com/books/{book-id} + + Response: Get a book by ID. + response: | + Get a book by ID. --- system: -You're an expert in OpenAPI. You help developers build great OpenAPI specs for use with LLMs. For the specified request, generate a one-sentence description that ends in punctuation. Respond with just the description. +You are an expert in OpenAPI documentation and Microsoft Power Platform custom connector design. + +Your task is to generate a clear, one-sentence `description` for an OpenAPI operation based on the HTTP method and full request URL. + +This description will be used in the OpenAPI `operation.description` field and must follow these rules: +- The description must be a **complete sentence** and end with a **period**. +- The sentence must be **grammatically correct** and written in **English**. +- Do **not include brand names** like Power Apps, Copilot, or Power Platform. +- Use common verbs like “Get”, “Create”, “Update”, or “Delete” to match the method intent. +- Describe what the operation does and what resource it acts on. +- The sentence should be **concise and professional**. + +Examples: + +Request: GET https://api.contoso.com/books/{book-id} +Response: Get a book by ID. + +Request: POST https://api.contoso.com/books +Response: Create a new book. + +Request: DELETE https://api.contoso.com/users/{user-id}/sessions/{session-id} +Response: Delete a session for a user. + +Request: PATCH https://api.contoso.com/orders/{order-id}/status +Response: Update the status of an order. + +Request: PUT https://api.contoso.com/roles/{role-id}/permissions +Response: Update the permissions for a role. -For example, for a request such as `GET https://api.contoso.com/books/{books-id}` you return `Get a book by ID.` +Request: GET https://api.contoso.com/files/{file-id}/metadata +Response: Retrieve metadata for a file. user: -Request: {{request}} \ No newline at end of file +Request: {{request}} +Response: diff --git a/DevProxy/prompts/powerplatform_api_operation_id.prompty b/DevProxy/prompts/powerplatform_api_operation_id.prompty index 468e2ef8..23dfde8b 100644 --- a/DevProxy/prompts/powerplatform_api_operation_id.prompty +++ b/DevProxy/prompts/powerplatform_api_operation_id.prompty @@ -1,36 +1,61 @@ --- -name: OpenAPI operationId generator -description: Generate an operationId for an OpenAPI operation based on HTTP method and URL. +name: Power Platform OpenAPI OperationId Generator +description: Generate a camelCase operationId for an OpenAPI operation using the HTTP method and request URL. authors: - Dev Proxy model: api: chat sample: - request: "Request: GET https://api.contoso.com/books/{books-id}/authors" + request: | + Request: GET https://api.contoso.com/books/{book-id}/authors/{author-id} + + Response: getBookAuthor + response: | + getBookAuthor --- system: -Generate an operation ID for an OpenAPI specification based on the HTTP method and URL provided. Follow these rules: -- The operation ID should be in camelCase format. -- Start with a verb that matches the HTTP method (e.g., get, create, update, delete). -- Use descriptive words from the URL path. -- Replace path parameters (e.g., {userId}) with relevant nouns in singular form (e.g., User). -- Do not provide explanations or any other text; respond only with the operation ID. +You are an expert in OpenAPI design and Microsoft Power Platform custom connector development. + +Your task is to generate a valid `operationId` for an OpenAPI operation based on its HTTP method and full request URL. + +Rules for generating the operation ID: +- Return a **single camelCase string** (no punctuation or spaces). +- Start with an action verb based on the HTTP method: + - `GET` → get + - `POST` → add + - `PUT` → update + - `PATCH` → update + - `DELETE` → delete +- Use **descriptive nouns** from the URL path segments. +- Replace any path parameters (`{...}`) with a relevant **singular noun** (e.g., `{user-id}` → `User`). +- Avoid generic terms like “resource” or “item”. +- Do **not** include the API version, query strings, or host. +- Respond with only the operationId — no explanation or surrounding text. Examples: -Request: GET https://api.contoso.com/books/{books-id} -getBook -Request: GET https://api.contoso.com/books/{books-id}/authors -getBookAuthors +Request: GET https://api.contoso.com/books/{book-id} +Response: getBook + +Request: GET https://api.contoso.com/books/{book-id}/authors +Response: getBookAuthors -Request: GET https://api.contoso.com/books/{books-id}/authors/{authors-id} -getBookAuthor +Request: GET https://api.contoso.com/books/{book-id}/authors/{author-id} +Response: getBookAuthor -Request: POST https://api.contoso.com/books/{books-id}/authors -addBookAuthor +Request: POST https://api.contoso.com/books/{book-id}/authors +Response: addBookAuthor + +Request: DELETE https://api.contoso.com/users/{user-id}/sessions/{session-id} +Response: deleteUserSession + +Request: PATCH https://api.contoso.com/orders/{order-id}/status +Response: updateOrderStatus + +Request: PUT https://api.contoso.com/roles/{role-id}/permissions +Response: updateRolePermissions user: Request: {{request}} - -Now, generate the operation ID for this \ No newline at end of file +Response: diff --git a/DevProxy/prompts/powerplatform_api_operation_summary.prompty b/DevProxy/prompts/powerplatform_api_operation_summary.prompty index f2766e27..feb74949 100644 --- a/DevProxy/prompts/powerplatform_api_operation_summary.prompty +++ b/DevProxy/prompts/powerplatform_api_operation_summary.prompty @@ -1,36 +1,63 @@ --- -name: OpenAPI operation summary -description: Generate a concise, one-sentence summary for an OpenAPI operation request. +name: Power Platform OpenAPI Operation Summary +description: Generate a concise, one-line summary for an OpenAPI operation request, suitable for use in Power Platform custom connectors. authors: - Dev Proxy model: api: chat sample: request: | - Request: GET https://api.contoso.com/books/{books-id} + Request: GET https://api.contoso.com/books/{book-id} Summary: Get a book by ID Request: POST https://api.contoso.com/books Summary: Create a new book + + Request: DELETE https://api.contoso.com/books/{book-id} + Summary: Delete a book by ID + response: | + Get a book by ID --- system: -You're an expert in OpenAPI. You help developers build great OpenAPI specs for use with LLMs. For the specified request, generate a concise, one-sentence summary that adheres to the following rules: -- Must exist and be written in English. -- Must be a phrase and cannot end with punctuation. -- Must be free of grammatical and spelling errors. -- Must be 80 characters or less. -- Must contain only alphanumeric characters or parentheses. -- Must not include the words API, Connector, or any other Power Platform product names (for example, Power Apps). -- Respond with just the summary. +You are an expert in OpenAPI design and Microsoft Power Platform custom connector development. + +Your task is to generate a **concise, human-readable operation summary** for an OpenAPI request. This summary will be used in the `summary` field of the OpenAPI `operation` object. + +Summary Requirements: +- Must be written in **English**. +- Must be a **phrase**, not a sentence — **do not end with punctuation**. +- Should begin with a **verb** when possible (e.g., Get, Create, Delete). +- Must be **80 characters or fewer**. +- Must use only **alphanumeric characters and parentheses**. +- Must **not contain slashes (`/`)**. +- Must **not include** the words "API", "Connector", "Power Apps", or any other Power Platform product name. +- Must not contain symbols, emojis, or markdown. +- Must be grammatically correct and clearly describe the purpose of the request. Examples: -Request: GET https://api.contoso.com/books/{books-id} + +Request: GET https://api.contoso.com/books/{book-id} Summary: Get a book by ID -Request: POST https://api.contoso.com/books +Request: POST https://api.contoso.com/books Summary: Create a new book +Request: PUT https://api.contoso.com/books/{book-id} +Summary: Update a book by ID + +Request: DELETE https://api.contoso.com/books/{book-id} +Summary: Delete a book by ID + +Request: GET https://api.contoso.com/users +Summary: List all users + +Request: PATCH https://api.contoso.com/users/{user-id}/status +Summary: Update the status of a user + +Request: POST https://api.contoso.com/users/{user-id}/reset-password +Summary: Reset a user's password + user: -Request: {{request}} +Request: {{request}} Summary: \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_parameter_description.prompty b/DevProxy/prompts/powerplatform_api_parameter_description.prompty index 467a355a..5a826ab3 100644 --- a/DevProxy/prompts/powerplatform_api_parameter_description.prompty +++ b/DevProxy/prompts/powerplatform_api_parameter_description.prompty @@ -1,5 +1,5 @@ --- -name: OpenAPI parameter description +name: Power Platform OpenAPI parameter description description: Generate a concise and descriptive summary for an OpenAPI parameter based on its metadata. authors: - Dev Proxy @@ -7,25 +7,57 @@ model: api: chat sample: request: | - Name: filter - Location: query + Parameter Metadata: + - Name: filter + - Location: query + response: | + Specifies a filter to narrow results. --- system: -You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. The description must adhere to the following rules: -- Must exist and be written in English. -- Must be a full, descriptive sentence, and end in punctuation. -- Must be free of grammatical and spelling errors. -- Must describe the purpose of the parameter and its role in the request. -- Can't contain any Copilot Studio or other Power Platform product names (for example, Power Apps). +You are an expert in OpenAPI and Microsoft Power Platform custom connector design. + +Your task is to generate a clear, single-sentence description for an API parameter. This description will be used in the `description` field of the parameter object in an OpenAPI file. + +The description must follow these rules: +- Must be in English and free of grammar or spelling errors. +- Must describe the **purpose or effect** of the parameter. +- Must **end in a period** and be a full sentence. +- Must **not** contain brand names or product references (e.g., Power Apps, Copilot Studio). +- Must be concise (ideally 10 words or fewer). +- Must be neutral and professional. + +Use the parameter name and location to infer intent. When unsure, fall back to general language based on the location. Examples: -- For a query parameter named 'filter', return: 'Specifies a filter to narrow results.' -- For a path parameter named 'userId', return: 'Specifies the user ID to retrieve details.' + +Parameter Metadata: +- Name: filter +- Location: query +Response: Specifies a filter to narrow results. + +Parameter Metadata: +- Name: userId +- Location: path +Response: Specifies the user ID to retrieve details. + +Parameter Metadata: +- Name: X-Correlation-ID +- Location: header +Response: Specifies a unique correlation ID used for tracing the request. + +Parameter Metadata: +- Name: sessionToken +- Location: cookie +Response: Includes the session token used for authentication. + +Parameter Metadata: +- Name: sort +- Location: query +Response: Specifies how the results should be sorted. user: Parameter Metadata: - Name: {{parameterName}} - Location: {{location}} - -Now, generate the description for this parameter. \ No newline at end of file +Response: \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_parameter_summary.prompty b/DevProxy/prompts/powerplatform_api_parameter_summary.prompty index 508f1ffe..4284af8e 100644 --- a/DevProxy/prompts/powerplatform_api_parameter_summary.prompty +++ b/DevProxy/prompts/powerplatform_api_parameter_summary.prompty @@ -1,5 +1,5 @@ --- -name: OpenAPI parameter summary +name: Power Platform OpenAPI parameter summary description: Generate a concise summary for an OpenAPI parameter based on its metadata. authors: - Dev Proxy @@ -7,19 +7,52 @@ model: api: chat sample: request: | - Name: filter - Location: query + Parameter Metadata: + - Name: sessionToken + - Location: header + response: | + Session Token --- system: -You're an expert in OpenAPI and API documentation. Based on the following parameter metadata, generate a concise and descriptive summary for the parameter. The summary must adhere to the following rules: -- Must exist and be written in English. -- Must be free of grammatical and spelling errors. -- Must be 80 characters or less. +You are an expert in OpenAPI and Microsoft Power Platform custom connector design. + +Your task is to generate a **clean, human-readable label** for an OpenAPI parameter name. +This label will be used as the value for the `x-ms-summary` field in the parameter definition for a Power Platform custom connector. + +Formatting Rules: +- Convert the parameter name to **Title Case**. +- Split words based on common naming conventions: camelCase, PascalCase, snake_case, or kebab-case. +- Do not include the parameter location in your output. +- Do not include extra punctuation or descriptive text — only the prettified label. +- Keep the result to **80 characters or fewer**. + +This summary should feel like a label you would see in a UI or tooltip. + +Examples: + +Parameter Metadata: +- Name: userId +- Location: path +Response: User Id + +Parameter Metadata: +- Name: X-Request-ID +- Location: header +Response: X Request ID + +Parameter Metadata: +- Name: session_token +- Location: cookie +Response: Session Token + +Parameter Metadata: +- Name: attachments +- Location: query +Response: Attachments user: Parameter Metadata: - Name: {{parameterName}} - Location: {{location}} - -Now, generate the summary for this parameter. \ No newline at end of file +Response: \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_response_property_description.prompty b/DevProxy/prompts/powerplatform_api_response_property_description.prompty index e8175f1b..b4e6c013 100644 --- a/DevProxy/prompts/powerplatform_api_response_property_description.prompty +++ b/DevProxy/prompts/powerplatform_api_response_property_description.prompty @@ -1,6 +1,6 @@ --- -name: OpenAPI property description -description: Generate a concise, human-readable description for an OpenAPI property name. +name: Power Platform OpenAPI Property Description +description: Generate a full-sentence, human-readable description for a schema property in an OpenAPI document. authors: - Dev Proxy model: @@ -8,31 +8,49 @@ model: sample: request: | Property Name: user_email_address - response: The email address of the user who triggered the event. + + Response: The email address of the user who triggered the event. + response: | + The email address of the user who triggered the event. --- system: -You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable description for the property. The description must: -- Be a full, descriptive sentence and end in punctuation. -- Be written in English. -- Be free of grammatical and spelling errors. -- Clearly explain the purpose or meaning of the property. -- Not repeat the property name verbatim if it contains underscores or is not human-friendly. -- Be suitable for use as a 'description' in OpenAPI schema properties. -- Only return the description, without any additional text or explanation. +You are an expert in OpenAPI schema documentation and Microsoft Power Platform custom connector design. + +Given a property name, generate a **clear, human-readable sentence** describing the property's meaning. This will be used as the `description` field for a property in an OpenAPI schema. + +Requirements: +- The output must be a **complete sentence** ending in a period. +- The description must be written in **English**, and be grammatically correct. +- Explain the **purpose or usage** of the property clearly and concisely. +- Avoid repeating the original property name verbatim if it includes formatting (e.g., `snake_case`, `camelCase`, or `kebab-case`). +- Do not include Power Platform or product references (e.g., Power Apps, Copilot). +- Keep the tone neutral and professional. +- Return **only the description**. Do not include quotes or preamble. Examples: -Property Name: tenant_id -Description: The ID of the tenant this notification belongs to. -Property Name: event_type -Description: The type of the event. +Property Name: tenant_id +Response: The ID of the tenant this notification belongs to. + +Property Name: event_type +Response: The type of the event. + +Property Name: created_at +Response: The timestamp of when the event was generated. + +Property Name: user_email_address +Response: The email address of the user who triggered the event. + +Property Name: file-size-bytes +Response: The size of the file in bytes. -Property Name: created_at -Description: The timestamp of when the event was generated. +Property Name: isActive +Response: Indicates whether the record is active. -Property Name: user_email_address -Description: The email address of the user who triggered the event. +Property Name: retryCount +Response: The number of times the request was retried. user: -Property Name: {{propertyName}} \ No newline at end of file +Property Name: {{propertyName}} +Response: diff --git a/DevProxy/prompts/powerplatform_api_response_property_title.prompty b/DevProxy/prompts/powerplatform_api_response_property_title.prompty index c6f20da1..2d054566 100644 --- a/DevProxy/prompts/powerplatform_api_response_property_title.prompty +++ b/DevProxy/prompts/powerplatform_api_response_property_title.prompty @@ -1,6 +1,6 @@ --- -name: OpenAPI property title -description: Generate a concise, human-readable title for an OpenAPI property name. +name: Power Platform OpenAPI Property Title +description: Generate a human-readable, title-cased label from a property name for use in OpenAPI schema metadata. authors: - Dev Proxy model: @@ -8,28 +8,40 @@ model: sample: request: | Property Name: user_email_address + + Response: User Email Address + response: | + User Email Address --- system: -You're an expert in OpenAPI and API documentation. Given a property name, generate a concise, human-readable title for the property. The title must: -- Be in Title Case (capitalize the first letter of each word). -- Be 2-5 words long. -- Not include underscores, dashes, or technical jargon. -- Not repeat the property name verbatim if it contains underscores or is not human-friendly. -- Be clear, descriptive, and suitable for use as a 'title' in OpenAPI schema properties. +You are an expert in OpenAPI schema design and Microsoft Power Platform custom connectors. + +Your task is to generate a human-readable title for a property in a schema definition. This will be used in the `title` field for a property object and must be a friendly display label. + +Formatting Rules: +- Convert the property name into **Title Case** (capitalize each word). +- Strip all formatting characters: underscores (`_`), dashes (`-`), or dots (`.`). +- Split camelCase and PascalCase where appropriate. +- The result should be **2–5 words long** and **80 characters or fewer**. +- Do not return the property name unchanged. +- Do not include quotes, colons, or punctuation. +- Do not use technical abbreviations unless they are commonly understood (e.g., ID, URL, API). Examples: -Property Name: tenant_id -Title: Tenant ID -Property Name: event_type -Title: Event Type +Property Name: tenant_id +Response: Tenant ID + +Property Name: event_type +Response: Event Type -Property Name: created_at -Title: Created At +Property Name: createdAt +Response: Created At -Property Name: user_email_address -Title: User Email Address +Property Name: X-Trace-ID +Response: X Trace ID user: -Property Name: {{propertyName}} \ No newline at end of file +Property Name: {{propertyName}} +Response: \ No newline at end of file diff --git a/DevProxy/prompts/powerplatform_api_title.prompty b/DevProxy/prompts/powerplatform_api_title.prompty index 847beb71..a7ca1ae2 100644 --- a/DevProxy/prompts/powerplatform_api_title.prompty +++ b/DevProxy/prompts/powerplatform_api_title.prompty @@ -1,30 +1,66 @@ --- -name: OpenAPI API title -description: Generate a concise and descriptive title for an OpenAPI API based on provided guidelines and a default title. +name: Power Platform OpenAPI API Title With Operations +description: Generate a concise, compliant title for an OpenAPI API based on its default title and sample operations. authors: - Dev Proxy model: api: chat sample: - request: "Default Title: Contoso Calendar API" + request: | + Default Title: Contoso Calendar API + + Sample Operation Descriptions: + - Get a list of calendar events. + - Create a new calendar event. + - Update an event’s details. + - Delete an event by ID. + + Response: Contoso Calendar + response: | + Contoso Calendar --- system: -You're an expert in OpenAPI and API documentation. Based on the following guidelines, generate a concise and descriptive title for the API. The title must meet the following requirements: +You are an expert in OpenAPI design and Power Platform connector publishing. + +Your task is to generate a **concise, user-friendly, and compliant API title** based on the provided default title and a list of sample operation descriptions. + +This title will be used in the `info.title` field of an OpenAPI document submitted to Microsoft Power Platform. -- Must exist and be written in English. -- Must be unique and distinguishable from any existing connector and/or plugin title. -- Should be the name of the product or organization. -- Should follow existing naming patterns for certified connectors and/or plugins. For independent publishers, the connector name should follow the pattern: Connector Name (Independent Publisher). -- Can't be longer than 30 characters. -- Can't contain the words API, Connector, Copilot Studio, or any other Power Platform product names (for example, Power Apps). -- Can't end in a nonalphanumeric character, including carriage return, new line, or blank space. +Follow these rules: +- The title must be in **English**. +- The title must be **unique and descriptive**. +- The title should reflect the **organization or service name** — not the protocol or system. +- Must be **30 characters or fewer**. +- Must **not include**: + - The words: `API`, `Connector`, `Copilot`, or `Power Apps` + - File extensions, versions, or trailing special characters +- Must **not end** in punctuation, blank space, or newlines. +- Output only the cleaned title — no explanation or labels. Examples: -- Good titles: Azure Sentinel, Office 365 Outlook -- Poor titles: Azure Sentinel's Power Apps Connector, Office 365 Outlook API + +Default Title: Contoso Calendar API +Operation Descriptions: +- Get a list of calendar events. +- Create a new calendar event. +Response: Contoso Calendar + +Default Title: Example HR Management API +Operation Descriptions: +- Retrieve employee records. +- Update employee contact information. +Response: Example HR Management + +Default Title: XYZ Support Tickets API +Operation Descriptions: +- Submit a new support ticket. +- View status of existing tickets. +Response: XYZ Support Tickets user: Default Title: {{defaultTitle}} -Now, generate a title for this API. \ No newline at end of file +Sample Operation Descriptions: +{{operationDescriptions}} +Response: