diff --git a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs index 55e33bae..10811b82 100644 --- a/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/OpenApiSpecGeneratorPlugin.cs @@ -50,14 +50,45 @@ public enum SpecFormat Yaml } +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 +{ + 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 sealed class OpenApiSpecGeneratorPlugin( +public class OpenApiSpecGeneratorPlugin( ILogger logger, ISet urlsToWatch, ILanguageModelClient languageModelClient, @@ -92,9 +123,9 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e) 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; } @@ -113,17 +144,7 @@ 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); } catch (Exception ex) @@ -136,6 +157,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) + await ProcessOpenApiDocumentAsync(openApiDoc); + var server = openApiDoc.Servers.First(); var fileName = GetFileNameFromServerUrl(server.Url, Configuration.SpecFormat); @@ -176,30 +200,70 @@ request.Context.Session is null || Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); } - private async Task GetOperationIdAsync(string method, string serverUrl, string parametrizedPath) + /// + /// 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 async Task ProcessPathItemAsync(OpenApiPathItem pathItem, Uri requestUri, string parametrizedPath) + { + 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 + ); + } + + /// + /// Allows derived plugins to post-process the OpenApiDocument before it is serialized and written to disk. + /// + /// The OpenApiDocument to process. + protected virtual Task ProcessOpenApiDocumentAsync(OpenApiDocument openApiDoc) { + // By default, do nothing. Derived plugins can override to add/modify document-level data. + return Task.CompletedTask; + } + + 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}" } }); } - return id?.Response ?? $"{method}{parametrizedPath.Replace('/', '.')}"; + return id?.Response?.Trim() ?? $"{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}" } }); } - 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 new file mode 100644 index 00000000..8baec480 --- /dev/null +++ b/DevProxy.Plugins/Generation/PowerPlatformOpenApiSpecGeneratorPlugin.cs @@ -0,0 +1,551 @@ +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 sealed 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; + 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. + /// 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; + } + + /// + /// Processes the OpenAPI document to set contact information, title, description, and connector metadata. + /// This method is called asynchronously during the OpenAPI document processing. + /// + /// The OpenAPI document to process. + /// Thrown if is null. + protected override async Task ProcessOpenApiDocumentAsync(OpenApiDocument openApiDoc) + { + ArgumentNullException.ThrowIfNull(openApiDoc); + openApiDoc.Info.Contact = Configuration.Contact?.ToOpenApiContact(); + + // 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."); + } + + var (apiDescription, operationDescriptions) = await SetTitleAndDescription(openApiDoc, serverUrl); + var metadata = await GenerateConnectorMetadataAsync(serverUrl, apiDescription, operationDescriptions); + 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. + /// + /// The OpenAPI document to process. + /// 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) + { + 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); + } + + /// + /// Removes the x-ms-connector-metadata extension from the OpenAPI document if it exists + /// and is empty. + /// + /// The OpenAPI document to process. + /// 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; + + if (await _languageModelClient.IsEnabledAsync()) + { + description = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_description", new() + { + { "defaultDescription", defaultDescription }, + { "operationDescriptions", operationDescriptions } + }); + } + + 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. + /// 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 }, + { "operationDescriptions", operationDescriptions } + }); + } + + // 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. + /// + /// 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.GetLeftPart(UriPartial.Authority); + 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); + + // Combine operation-level and path-level parameters + var allParameters = new List(); + allParameters.AddRange(operation.Parameters); + allParameters.AddRange(pathItem.Parameters); + + foreach (var parameter in allParameters) + { + 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 is null) + { + continue; + } + + foreach (var response in operation.Responses.Values) + { + if (response.Content is null) + { + continue; + } + + foreach (var mediaType in response.Content.Values) + { + if (mediaType.Schema is null) + { + continue; + } + + await ProcessSchemaPropertiesAsync(mediaType.Schema); + } + } + } + RemoveResponseHeadersIfDisabled(pathItem); + } + + /// + /// Recursively processes all properties of an , setting their title and description using LLM or fallback logic. + /// + /// The OpenAPI schema to process. + 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); + } + } + + /// + /// 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 is null) + { + return; + } + + foreach (var operation in pathItem.Operations.Values) + { + if (operation.Responses is null) + { + continue; + } + + 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 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") + { + return base.GetOperationIdAsync(method, serverUrl, parametrizedPath, promptyFile); + } + + /// + /// Generates an operationId for an OpenAPI operation using LLM or fallback logic. + /// + /// The HTTP method. + /// The server URL. + /// The parametrized path. + /// 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") + { + return base.GetOperationDescriptionAsync(method, serverUrl, parametrizedPath, promptyFile); + } + + /// + /// 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) + { + 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}"; + } + + /// + /// 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) + { + ILanguageModelCompletionResponse? response = null; + + if (await _languageModelClient.IsEnabledAsync()) + { + 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 + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : 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) + { + ILanguageModelCompletionResponse? response = null; + + if (await _languageModelClient.IsEnabledAsync()) + { + 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 + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : 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 + { + 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}'." + }; + } + + /// + /// 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 + { + 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." + }; + } + + /// + /// Generates a title for a response property using LLM or fallback logic. + /// + /// The property name. + /// The generated title. + private async Task GetResponsePropertyTitleAsync(string propertyName) + { + ILanguageModelCompletionResponse? response = null; + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_response_property_title", new() + { + { "propertyName", propertyName } + }); + } + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetResponsePropertyTitleFallback(propertyName); + } + + /// + /// 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 + 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; + } + + /// + /// Generates a description for a response property using LLM or fallback logic. + /// + /// The property name. + /// The generated description. + private async Task GetResponsePropertyDescriptionAsync(string propertyName) + { + ILanguageModelCompletionResponse? response = null; + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_response_property_description", new() + { + { "propertyName", propertyName } + }); + } + return !string.IsNullOrWhiteSpace(response?.Response) + ? response.Response.Trim() + : GetResponsePropertyDescriptionFallback(propertyName); + } + + /// + /// 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 + 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}."; + } + + /// + /// 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, string apiDescription, string operationDescriptions) + { + var website = Configuration.ConnectorMetadata?.Website ?? serverUrl; + var privacyPolicy = Configuration.ConnectorMetadata?.PrivacyPolicy ?? serverUrl; + + string categories; + var categoriesList = Configuration.ConnectorMetadata?.Categories; + if (categoriesList != null && categoriesList.Count > 0) + { + categories = string.Join(", ", categoriesList); + } + else + { + categories = await GetConnectorMetadataCategoriesAsync(serverUrl, apiDescription, operationDescriptions); + } + + 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; + } + + /// + /// Generates the categories for connector metadata using LLM or configuration. + /// + /// The server URL for context. + /// 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; + + if (await _languageModelClient.IsEnabledAsync()) + { + response = await _languageModelClient.GenerateChatCompletionAsync("powerplatform_api_connector_metadata_categories", new() + { + { "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.Trim() + : "Data"; + } + + /// + /// 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) + { + if (openApiDoc?.Extensions != null && openApiDoc.Extensions.ContainsKey("x-ms-generated-by")) + { + // Remove the x-ms-generated-by extension if it exists + _ = openApiDoc.Extensions.Remove("x-ms-generated-by"); + } + } +} diff --git a/DevProxy/DevProxy.csproj b/DevProxy/DevProxy.csproj index 29ddd8f1..1500fac8 100644 --- a/DevProxy/DevProxy.csproj +++ b/DevProxy/DevProxy.csproj @@ -64,6 +64,42 @@ PreserveNewest + + 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..a7763a69 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_connector_metadata_categories.prompty @@ -0,0 +1,68 @@ +--- +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: + api: chat +sample: + request: | + 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 + response: | + Collaboration, Content and Files +--- + +system: +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. + +These categories are used in the Power Platform custom connector metadata field `x-ms-connector-metadata.categories`. + +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: +- 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 +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}} +API Description: {{apiDescription}} +Operation Descriptions: +{{operationDescriptions}} +Response: \ 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..74c9d263 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_description.prompty @@ -0,0 +1,72 @@ +--- +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: | + 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 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 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. + +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: +- Info Description: {{defaultDescription}} + +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 new file mode 100644 index 00000000..9711351b --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_operation_description.prompty @@ -0,0 +1,52 @@ +--- +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/{book-id} + + Response: Get a book by ID. + response: | + Get a book by ID. +--- + +system: +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. + +Request: GET https://api.contoso.com/files/{file-id}/metadata +Response: Retrieve metadata for a file. + +user: +Request: {{request}} +Response: diff --git a/DevProxy/prompts/powerplatform_api_operation_id.prompty b/DevProxy/prompts/powerplatform_api_operation_id.prompty new file mode 100644 index 00000000..23dfde8b --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_operation_id.prompty @@ -0,0 +1,61 @@ +--- +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/{book-id}/authors/{author-id} + + Response: getBookAuthor + response: | + getBookAuthor +--- + +system: +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/{book-id} +Response: getBook + +Request: GET https://api.contoso.com/books/{book-id}/authors +Response: getBookAuthors + +Request: GET https://api.contoso.com/books/{book-id}/authors/{author-id} +Response: getBookAuthor + +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}} +Response: diff --git a/DevProxy/prompts/powerplatform_api_operation_summary.prompty b/DevProxy/prompts/powerplatform_api_operation_summary.prompty new file mode 100644 index 00000000..feb74949 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_operation_summary.prompty @@ -0,0 +1,63 @@ +--- +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/{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 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/{book-id} +Summary: Get a book by ID + +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}} +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 new file mode 100644 index 00000000..5a826ab3 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_parameter_description.prompty @@ -0,0 +1,63 @@ +--- +name: Power Platform 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: | + Parameter Metadata: + - Name: filter + - Location: query + response: | + Specifies a filter to narrow results. +--- + +system: +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: + +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}} +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 new file mode 100644 index 00000000..4284af8e --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_parameter_summary.prompty @@ -0,0 +1,58 @@ +--- +name: Power Platform OpenAPI parameter summary +description: Generate a concise summary for an OpenAPI parameter based on its metadata. +authors: + - Dev Proxy +model: + api: chat +sample: + request: | + Parameter Metadata: + - Name: sessionToken + - Location: header + response: | + Session Token +--- + +system: +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}} +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 new file mode 100644 index 00000000..b4e6c013 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_response_property_description.prompty @@ -0,0 +1,56 @@ +--- +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: + api: chat +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. +--- + +system: +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 +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: isActive +Response: Indicates whether the record is active. + +Property Name: retryCount +Response: The number of times the request was retried. + +user: +Property Name: {{propertyName}} +Response: 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..2d054566 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_response_property_title.prompty @@ -0,0 +1,47 @@ +--- +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: + api: chat +sample: + request: | + Property Name: user_email_address + + Response: User Email Address + response: | + User Email Address +--- + +system: +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 +Response: Tenant ID + +Property Name: event_type +Response: Event Type + +Property Name: createdAt +Response: Created At + +Property Name: X-Trace-ID +Response: X Trace ID + +user: +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 new file mode 100644 index 00000000..a7ca1ae2 --- /dev/null +++ b/DevProxy/prompts/powerplatform_api_title.prompty @@ -0,0 +1,66 @@ +--- +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 + + 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 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. + +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: + +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}} + +Sample Operation Descriptions: +{{operationDescriptions}} +Response: diff --git a/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json new file mode 100644 index 00000000..e803056d --- /dev/null +++ b/schemas/v0.29.0/powerplatformopenapispecgeneratorplugin.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Dev Proxy PowerPlatformOpenApiSpecGeneratorPlugin config schema", + "type": "object", + "properties": { + "$schemaReference": { + "type": "string", + "description": "The JSON schema reference for validation." + }, + "connectorMetadata": { + "type": "object", + "description": "Optional metadata for the connector.", + "properties": { + "categories": { + "type": "array", + "description": "An array of categories for the API.", + "items": { + "type": "string", + "description": "A category for the API." + }, + "default": ["Data"] + }, + "privacyPolicy": { + "type": "string", + "format": "uri", + "description": "The privacy policy URL for the API." + }, + "website": { + "type": "string", + "format": "uri", + "description": "The corporate website URL for the API." + } + }, + "additionalProperties": false + }, + "contact": { + "type": "object", + "description": "Contact information for the API.", + "properties": { + "email": { + "type": "string", + "format": "email", + "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.", + "default": "Your Name" + }, + "url": { + "type": "string", + "format": "uri", + "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 + }, + "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 +} \ No newline at end of file