diff --git a/README.md b/README.md index 99807be..cce2494 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ Perfect for developers seeking both flexibility and precision in their API solut ## Example ```cs - var collection = new ServiceCollection(); using var services = collection.BuildServiceProvider(); services.AddOpenApi(opt => @@ -37,6 +36,53 @@ services.AddOpenApi(opt => ## Extensions +### Enrich with XML comments +XML documentation is often used to make a comprehensive documentation to your API documentation. +By using XML comments it is easy to describe various aspects of your code, including methods, properties, parameters, return values, and more. + +To create a detailed documentation using XML comments, you need to enable it in your project. Like shown below: + +```xml + + + + net9.0 + enable + enable + true + + + + + + + + +``` + +After enabling XML Comments you will maybe see a new warning show up `CS1591`, to suppress the warning add this to your csproj file: + +```xml +... + + $(NoWarn);1591 + +... +``` + +Lastly add it to your Open Api configuration + +```cs +var collection = new ServiceCollection(); +using var services = collection.BuildServiceProvider(); +services.AddOpenApi(opt => + { + opt.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0; + opt.ConfigureNodaTime(); + opt.AddXmlComments(); + }); +``` + ## NodaTime Allows to configure Asp.Net Core and OpenApi to use NodaTime types. diff --git a/Samples/OpenApi.Sample/Module.cs b/Samples/OpenApi.Sample/Module.cs new file mode 100644 index 0000000..f9ce44d --- /dev/null +++ b/Samples/OpenApi.Sample/Module.cs @@ -0,0 +1,3 @@ +namespace OpenApi.Sample; + +internal class Module; diff --git a/Samples/OpenApi.Sample/OpenApi.Sample.csproj b/Samples/OpenApi.Sample/OpenApi.Sample.csproj index 62af060..aedb11b 100644 --- a/Samples/OpenApi.Sample/OpenApi.Sample.csproj +++ b/Samples/OpenApi.Sample/OpenApi.Sample.csproj @@ -3,6 +3,7 @@ net9.0 enable + true diff --git a/Samples/OpenApi.Sample/Program.cs b/Samples/OpenApi.Sample/Program.cs index 94e1b3b..1a33cf8 100644 --- a/Samples/OpenApi.Sample/Program.cs +++ b/Samples/OpenApi.Sample/Program.cs @@ -8,6 +8,7 @@ using NodaTime; using OpenApi.Extensions.Extensions; using OpenApi.NodaTime.Extensions; +using OpenApi.Sample; using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -18,6 +19,7 @@ { opt.AddDescription("This project contains samples on the extensions library OpenApi.Extensions."); opt.ConfigureNodaTime(); + opt.AddXmlComments(); opt.AddResponseType(HttpStatusCode.BadRequest); }); @@ -54,7 +56,13 @@ app.Run(); +/// +/// Shows the weather forecast +/// internal record WeatherForecast(LocalDate Date, int TemperatureC, string? Summary) { + /// + /// Uses TemperatureC to calculate TemperatureF + /// public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); } diff --git a/src/OpenApi.Extensions/Extensions/OpenApiOptionsExtensions.cs b/src/OpenApi.Extensions/Extensions/OpenApiOptionsExtensions.cs index 1ee2dda..ea78da1 100644 --- a/src/OpenApi.Extensions/Extensions/OpenApiOptionsExtensions.cs +++ b/src/OpenApi.Extensions/Extensions/OpenApiOptionsExtensions.cs @@ -2,13 +2,15 @@ using System.Collections.Generic; using System.Net; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; -using OpenApi.Extensions.Transformers; +using OpenApi.Extensions.Transformers.Documents; using OpenApi.Extensions.Transformers.Operations; +using OpenApi.Extensions.Transformers.Schemas; namespace OpenApi.Extensions.Extensions; @@ -466,6 +468,31 @@ public static OpenApiOptions AddType(this OpenApiOptions optio return options; } + /// + /// Loads the assembly and adds XML documentation to the type. + /// + /// The assembly type to add XML documentation for. + /// The updated instance. + public static OpenApiOptions AddXmlComments(this OpenApiOptions options) + where T : class, new() + { + options.AddSchemaTransformer>(); + return options; + } + + /// + /// Loads the assembly and adds XML documentation to the type. + /// + /// The assembly type to add XML documentation for. + /// The updated instance. + public static OpenApiOptions AddXmlComments(this OpenApiOptions options, + Func> onChangeDescription) + where T : class, new() + { + options.AddSchemaTransformer(new XmlSchemaTransformer(onChangeDescription)); + return options; + } + private static string FormatJson(T obj, JsonSerializerOptions? jsonSerializerOptions) { var formatToJson = JsonSerializer.Serialize(obj, jsonSerializerOptions); diff --git a/src/OpenApi.Extensions/Transformers/DescriptionDocumentTransform.cs b/src/OpenApi.Extensions/Transformers/Documents/DescriptionDocumentTransform.cs similarity index 91% rename from src/OpenApi.Extensions/Transformers/DescriptionDocumentTransform.cs rename to src/OpenApi.Extensions/Transformers/Documents/DescriptionDocumentTransform.cs index d2c3cd3..63b8ec1 100644 --- a/src/OpenApi.Extensions/Transformers/DescriptionDocumentTransform.cs +++ b/src/OpenApi.Extensions/Transformers/Documents/DescriptionDocumentTransform.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi.Models; -namespace OpenApi.Extensions.Transformers; +namespace OpenApi.Extensions.Transformers.Documents; public class DescriptionDocumentTransform : IOpenApiDocumentTransformer { diff --git a/src/OpenApi.Extensions/Transformers/Schemas/XmlSchemaTransformer.cs b/src/OpenApi.Extensions/Transformers/Schemas/XmlSchemaTransformer.cs new file mode 100644 index 0000000..0e459cf --- /dev/null +++ b/src/OpenApi.Extensions/Transformers/Schemas/XmlSchemaTransformer.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Reflection; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.XPath; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi.Models; + +namespace OpenApi.Extensions.Transformers.Schemas; + +public class XmlSchemaTransformer : IOpenApiSchemaTransformer + where T : class, new() +{ + private readonly ConcurrentDictionary _descriptions = []; + private readonly Func>? _getDescription; + private readonly Lazy _navigator; + + public XmlSchemaTransformer(Func>? getDescription = null) + { + _getDescription = getDescription; + _navigator = new Lazy(CreateNavigator); + } + + public async Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + var memberInfo = context.JsonPropertyInfo?.DeclaringType; + if (schema.Description is null && + memberInfo is not null && + GetMemberName(context.JsonTypeInfo, context.JsonPropertyInfo) is { Length: > 0 } memberName) + { + schema.Description = await GetDescription(memberName, context.JsonTypeInfo, context.JsonPropertyInfo); + } + } + + private async ValueTask GetDescription(string memberName, JsonTypeInfo typeInfo, JsonPropertyInfo? propertyInfo) + { + if (_descriptions.TryGetValue(memberName, out var description)) + { + return description; + } + + var navigator = _navigator.Value; + + var xpath = $"/doc/members/member[@name='{memberName}']/summary"; + var summaryNode = navigator?.SelectSingleNode(xpath); + description = summaryNode?.Value.Trim(); + if (_getDescription is not null) + { + description = await _getDescription(typeInfo, propertyInfo, description); + } + + _descriptions[memberName] = description; + + return description; + } + + private static XPathNavigator? CreateNavigator() + { + var assemblyPath = typeof(T).Assembly.Location; + var xmlPath = Path.ChangeExtension(assemblyPath, ".xml"); + + if (!File.Exists(xmlPath)) + { + return null; + } + + using var reader = XmlReader.Create(xmlPath); + return new XPathDocument(reader).CreateNavigator(); + } + + private static string? GetMemberName(JsonTypeInfo typeInfo, JsonPropertyInfo? propertyInfo) + { + if (propertyInfo is null) + { + return $"T:{typeInfo.Type.FullName}"; + } + + var declaringType = propertyInfo.DeclaringType.FullName; + if (declaringType is null) + { + return null; + } + + var memberName = propertyInfo.AttributeProvider switch + { + MemberInfo member => member.Name, + _ => $"{char.ToUpperInvariant(propertyInfo.Name[0])}{propertyInfo.Name[1..]}" + }; + + // Determine member type prefix: Property (P) or Field (F) + var memberType = propertyInfo.AttributeProvider switch + { + PropertyInfo => "P", + FieldInfo => "F", + _ => null + }; + + return memberType is null + ? null + : $"{memberType}:{declaringType}{Type.Delimiter}{memberName}"; + } +}