From 26a4e898ac985274c02158282232bab391f89d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20P=C4=9Bnka?= Date: Wed, 23 Aug 2023 12:37:50 +0200 Subject: [PATCH] #173: Deprecating endpoint mapping in favor of plain middleware --- README.md | 8 +- src/Saunter/ApplicationBuilderExtensions.cs | 15 ++ .../AsyncApiEndpointRouteBuilderExtensions.cs | 4 + src/Saunter/AsyncApiMiddleware.cs | 80 ++++---- src/Saunter/HttpContextExtensions.cs | 86 +++++++++ src/Saunter/UI/AsyncApiUiMiddleware.cs | 174 ++++++------------ 6 files changed, 206 insertions(+), 161 deletions(-) create mode 100644 src/Saunter/ApplicationBuilderExtensions.cs create mode 100644 src/Saunter/HttpContextExtensions.cs diff --git a/README.md b/README.md index 0f555d6e..c59c7f69 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,7 @@ See [examples/StreetlightsAPI](https://github.com/tehmantra/saunter/blob/main/ex 4. Add saunter middleware to host the AsyncApi json document. In the `Configure` method of `Startup.cs`: ```csharp - app.UseEndpoints(endpoints => - { - endpoints.MapAsyncApiDocuments(); - endpoints.MapAsyncApiUi(); - }); + app.UseAsyncApi(); ``` 5. Use the published AsyncApi document: @@ -253,7 +249,7 @@ Each document can be accessed by specifying the name in the URL ## Contributing -See our [contributing guide](https://github.com/tehmantra/saunter/blob/main/CONTRIBUTING.md/CONTRIBUTING.md). +See our [contributing guide](https://github.com/tehmantra/saunter/blob/main/CONTRIBUTING.md). Feel free to get involved in the project by opening issues, or submitting pull requests. diff --git a/src/Saunter/ApplicationBuilderExtensions.cs b/src/Saunter/ApplicationBuilderExtensions.cs new file mode 100644 index 00000000..63209f88 --- /dev/null +++ b/src/Saunter/ApplicationBuilderExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Builder; +using Saunter.UI; + +namespace Saunter; + +public static class ApplicationBuilderExtensions +{ + public static IApplicationBuilder UseAsyncApi(this IApplicationBuilder applicationBuilder) + { + applicationBuilder.UseMiddleware(); + applicationBuilder.UseMiddleware(); + + return applicationBuilder; + } +} \ No newline at end of file diff --git a/src/Saunter/AsyncApiEndpointRouteBuilderExtensions.cs b/src/Saunter/AsyncApiEndpointRouteBuilderExtensions.cs index 4473e4e8..cd4d8d00 100644 --- a/src/Saunter/AsyncApiEndpointRouteBuilderExtensions.cs +++ b/src/Saunter/AsyncApiEndpointRouteBuilderExtensions.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -12,6 +13,8 @@ public static class AsyncApiEndpointRouteBuilderExtensions /// /// Maps the AsyncAPI document endpoint /// + /// + [Obsolete("This property is obsolete. Use UseAsyncApi instead.", false)] public static IEndpointConventionBuilder MapAsyncApiDocuments( this IEndpointRouteBuilder endpoints) { @@ -29,6 +32,7 @@ public static IEndpointConventionBuilder MapAsyncApiDocuments( /// /// Maps the AsyncAPI UI endpoint(s) /// + [Obsolete("This property is obsolete. Use UseAsyncApi instead.", false)] public static IEndpointConventionBuilder MapAsyncApiUi(this IEndpointRouteBuilder endpoints) { var pipeline = endpoints.CreateApplicationBuilder() diff --git a/src/Saunter/AsyncApiMiddleware.cs b/src/Saunter/AsyncApiMiddleware.cs index e260d8b7..0d5f15f8 100644 --- a/src/Saunter/AsyncApiMiddleware.cs +++ b/src/Saunter/AsyncApiMiddleware.cs @@ -3,57 +3,57 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Saunter.Serialization; -using Saunter.Utils; -namespace Saunter +namespace Saunter; + +internal sealed class AsyncApiMiddleware { - public class AsyncApiMiddleware + private readonly RequestDelegate _next; + private readonly IAsyncApiDocumentProvider _asyncApiDocumentProvider; + private readonly IAsyncApiDocumentSerializer _asyncApiDocumentSerializer; + private readonly AsyncApiOptions _options; + + public AsyncApiMiddleware( + RequestDelegate next, + IOptions options, + IAsyncApiDocumentProvider asyncApiDocumentProvider, + IAsyncApiDocumentSerializer asyncApiDocumentSerializer) { - private readonly RequestDelegate _next; - private readonly IAsyncApiDocumentProvider _asyncApiDocumentProvider; - private readonly IAsyncApiDocumentSerializer _asyncApiDocumentSerializer; - private readonly AsyncApiOptions _options; + _next = next; + _asyncApiDocumentProvider = asyncApiDocumentProvider; + _asyncApiDocumentSerializer = asyncApiDocumentSerializer; + _options = options.Value; + } - public AsyncApiMiddleware(RequestDelegate next, IOptions options, IAsyncApiDocumentProvider asyncApiDocumentProvider, IAsyncApiDocumentSerializer asyncApiDocumentSerializer) + public async Task InvokeAsync(HttpContext context) + { + if (!context.IsRequestingAsyncApiDocument(_options)) { - _next = next; - _asyncApiDocumentProvider = asyncApiDocumentProvider; - _asyncApiDocumentSerializer = asyncApiDocumentSerializer; - _options = options.Value; + await _next(context); + return; } - public async Task Invoke(HttpContext context) + var prototype = _options.AsyncApi; + if (context.TryGetDocument(_options, out var documentName) && !_options.NamedApis.TryGetValue(documentName, out prototype)) { - if (!IsRequestingAsyncApiSchema(context.Request)) - { - await _next(context); - return; - } - - var prototype = _options.AsyncApi; - if (context.TryGetDocument(out var documentName) && !_options.NamedApis.TryGetValue(documentName, out prototype)) - { - await _next(context); - return; - } - - var asyncApiSchema = _asyncApiDocumentProvider.GetDocument(_options, prototype); - - await RespondWithAsyncApiSchemaJson(context.Response, asyncApiSchema, _asyncApiDocumentSerializer, _options); + await _next(context); + return; } - private static async Task RespondWithAsyncApiSchemaJson(HttpResponse response, AsyncApiSchema.v2.AsyncApiDocument asyncApiSchema, IAsyncApiDocumentSerializer asyncApiDocumentSerializer, AsyncApiOptions options) - { - var asyncApiSchemaJson = asyncApiDocumentSerializer.Serialize(asyncApiSchema); - response.StatusCode = (int)HttpStatusCode.OK; - response.ContentType = asyncApiDocumentSerializer.ContentType; + var asyncApiSchema = _asyncApiDocumentProvider.GetDocument(_options, prototype); - await response.WriteAsync(asyncApiSchemaJson); - } + await RespondWithAsyncApiSchemaJsonAsync(context.Response, asyncApiSchema, _asyncApiDocumentSerializer); + } - private bool IsRequestingAsyncApiSchema(HttpRequest request) - { - return HttpMethods.IsGet(request.Method) && request.Path.IsMatchingRoute(_options.Middleware.Route); - } + private static async Task RespondWithAsyncApiSchemaJsonAsync( + HttpResponse response, + AsyncApiSchema.v2.AsyncApiDocument asyncApiSchema, + IAsyncApiDocumentSerializer asyncApiDocumentSerializer) + { + var asyncApiSchemaJson = asyncApiDocumentSerializer.Serialize(asyncApiSchema); + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = asyncApiDocumentSerializer.ContentType; + + await response.WriteAsync(asyncApiSchemaJson); } } \ No newline at end of file diff --git a/src/Saunter/HttpContextExtensions.cs b/src/Saunter/HttpContextExtensions.cs new file mode 100644 index 00000000..83b4dc46 --- /dev/null +++ b/src/Saunter/HttpContextExtensions.cs @@ -0,0 +1,86 @@ +using System.Linq; +using Microsoft.AspNetCore.Http; + +namespace Saunter; + +internal static class HttpContextExtensions +{ + internal const string UriDocumentPlaceholder = "{document}"; + private const string UriDocumentPlaceholderEncoded = "%7Bdocument%7D"; + private const string UriDocumentFile = "/asyncapi.json"; + + public static bool TryGetDocument(this HttpContext context, AsyncApiOptions options, out string documentName) + { + foreach (var documentNameSpecified in options.NamedApis.Values.Select(x => x.DocumentName)) + { + var pathStart = options.Middleware.Route + .Replace(UriDocumentPlaceholder, documentNameSpecified) + .Replace(UriDocumentFile, string.Empty); + + if (!HttpMethods.IsGet(context.Request.Method) + || !context.Request.Path.StartsWithSegments(pathStart)) + { + continue; + } + + documentName = documentNameSpecified; + return true; + } + + documentName = string.Empty; + return false; + } + + public static bool IsRequestingUiBase(this HttpContext context, AsyncApiOptions options) + { + var uiBaseRoute = options.Middleware.UiBaseRoute; + return IsRequestingAsyncApiUrl(context, options, uiBaseRoute) + || IsRequestingAsyncApiUrl(context, options, uiBaseRoute.TrimEnd('/')); + } + + public static bool IsRequestingAsyncApiUi(this HttpContext context, AsyncApiOptions options) + { + var uiIndexRoute = options.Middleware.UiBaseRoute?.TrimEnd('/') + "/index.html"; + return context.IsRequestingAsyncApiUrl(options, uiIndexRoute); + } + + public static bool IsRequestingAsyncApiDocument(this HttpContext context, AsyncApiOptions options) + { + var uiIndexRoute = options.Middleware.Route; + return context.IsRequestingAsyncApiUrl(options, uiIndexRoute); + } + + public static string GetAsyncApiUiIndexFullRoute(this HttpContext context, AsyncApiOptions options) + { + var uiIndexRoute = options.Middleware.UiBaseRoute?.TrimEnd('/') + "/index.html"; + return context.GetFullRoute(options, uiIndexRoute); + } + + public static string GetAsyncApiDocumentFullRoute(this HttpContext context, AsyncApiOptions options) + { + var documentRoute = options.Middleware.Route; + return context.GetFullRoute(options, documentRoute); + } + + private static bool IsRequestingAsyncApiUrl(this HttpContext context, AsyncApiOptions options, string asyncApiBaseRoute) + { + if (context.TryGetDocument(options, out var documentName)) + { + asyncApiBaseRoute = asyncApiBaseRoute.Replace(UriDocumentPlaceholder, documentName); + } + + return HttpMethods.IsGet(context.Request.Method) && context.Request.Path.Equals(asyncApiBaseRoute); + } + + private static string GetFullRoute(this HttpContext context, AsyncApiOptions options, string route) + { + if (context.TryGetDocument(options, out var documentName)) + { + route = route + .Replace(UriDocumentPlaceholder, documentName) + .Replace(UriDocumentPlaceholderEncoded, documentName); + } + + return context.Request.PathBase.Add(route); + } +} \ No newline at end of file diff --git a/src/Saunter/UI/AsyncApiUiMiddleware.cs b/src/Saunter/UI/AsyncApiUiMiddleware.cs index c5b8e2b0..52f8adc5 100644 --- a/src/Saunter/UI/AsyncApiUiMiddleware.cs +++ b/src/Saunter/UI/AsyncApiUiMiddleware.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.IO; using System.Net; @@ -8,147 +7,92 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Routing.Template; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Saunter.Utils; -namespace Saunter.UI +namespace Saunter.UI; + +internal sealed class AsyncApiUiMiddleware { - public class AsyncApiUiMiddleware + private readonly AsyncApiOptions _options; + private readonly StaticFileMiddleware _staticFiles; + private readonly Dictionary _namedStaticFiles; + + public AsyncApiUiMiddleware(RequestDelegate next, IOptions options, IWebHostEnvironment env, ILoggerFactory loggerFactory) { - private readonly AsyncApiOptions _options; - private readonly StaticFileMiddleware _staticFiles; - private readonly Dictionary _namedStaticFiles; + _options = options.Value; + var fileProvider = new EmbeddedFileProvider(typeof(AsyncApiUiMiddleware).Assembly, "Saunter.UI"); + var staticFileOptions = new StaticFileOptions { RequestPath = GetUiBaseRoute(), FileProvider = fileProvider, }; + _staticFiles = new StaticFileMiddleware(next, env, Options.Create(staticFileOptions), loggerFactory); + _namedStaticFiles = new Dictionary(); - public AsyncApiUiMiddleware(RequestDelegate next, IOptions options, IWebHostEnvironment env, ILoggerFactory loggerFactory) + foreach (var namedApi in _options.NamedApis.Keys) { - _options = options.Value; - var fileProvider = new EmbeddedFileProvider(GetType().Assembly, GetType().Namespace); - var staticFileOptions = new StaticFileOptions - { - RequestPath = UiBaseRoute, - FileProvider = fileProvider, - }; - _staticFiles = new StaticFileMiddleware(next, env, Options.Create(staticFileOptions), loggerFactory); - _namedStaticFiles = new Dictionary(); - - foreach (var namedApi in _options.NamedApis) - { - var namedStaticFileOptions = new StaticFileOptions - { - RequestPath = UiBaseRoute.Replace("{document}", namedApi.Key), - FileProvider = fileProvider, - }; - _namedStaticFiles.Add(namedApi.Key, new StaticFileMiddleware(next, env, Options.Create(namedStaticFileOptions), loggerFactory)); - } + var namedStaticFileOptions = new StaticFileOptions { RequestPath = GetUiBaseRouteNamed(namedApi), FileProvider = fileProvider, }; + _namedStaticFiles.Add(namedApi, new StaticFileMiddleware(next, env, Options.Create(namedStaticFileOptions), loggerFactory)); } + } - public async Task Invoke(HttpContext context) + public async Task InvokeAsync(HttpContext context) + { + if (context.IsRequestingUiBase(_options)) { - if (IsRequestingUiBase(context.Request)) - { - context.Response.StatusCode = (int) HttpStatusCode.MovedPermanently; + context.Response.StatusCode = (int)HttpStatusCode.MovedPermanently; + context.Response.Headers["Location"] = context.GetAsyncApiUiIndexFullRoute(_options); + return; + } - if (context.TryGetDocument(out var document)) - { - context.Response.Headers["Location"] = GetUiIndexFullRoute(context.Request).Replace("{document}", document); - } - else - { - context.Response.Headers["Location"] = GetUiIndexFullRoute(context.Request); - } - return; - } + if (context.IsRequestingAsyncApiUi(_options)) + { + await RespondWithAsyncApiHtmlAsync(context.Response, context.GetAsyncApiDocumentFullRoute(_options)); + return; + } - if (IsRequestingAsyncApiUi(context.Request)) - { - if (context.TryGetDocument(out var document)) - { - await RespondWithAsyncApiHtml(context.Response, GetDocumentFullRoute(context.Request).Replace("{document}", document)); - } - else - { - await RespondWithAsyncApiHtml(context.Response, GetDocumentFullRoute(context.Request)); - } - return; - } - - if (!context.TryGetDocument(out var documentName)) + if (!context.TryGetDocument(_options, out var documentName)) + { + await _staticFiles.Invoke(context); + } + else + { + if (_namedStaticFiles.TryGetValue(documentName, out var files)) { - await _staticFiles.Invoke(context); + await files.Invoke(context); } else { - if (_namedStaticFiles.TryGetValue(documentName, out var files)) - { - await files.Invoke(context); - } - else - { - await _staticFiles.Invoke(context); - } - } - } - - private async Task RespondWithAsyncApiHtml(HttpResponse response, string route) - { - using (var stream = GetType().Assembly.GetManifestResourceStream($"{GetType().Namespace}.index.html")) - using (var reader = new StreamReader(stream)) - { - var indexHtml = new StringBuilder(await reader.ReadToEndAsync()); - - // Replace dynamic content such as the AsyncAPI document url - foreach (var replacement in new Dictionary - { - ["{{title}}"] = _options.Middleware.UiTitle, - ["{{asyncApiDocumentUrl}}"] = route, - }) - { - indexHtml.Replace(replacement.Key, replacement.Value); - } - - response.StatusCode = (int)HttpStatusCode.OK; - response.ContentType = MediaTypeNames.Text.Html; - await response.WriteAsync(indexHtml.ToString(), Encoding.UTF8); + await _staticFiles.Invoke(context); } } + } - private bool IsRequestingUiBase(HttpRequest request) - { - return HttpMethods.IsGet(request.Method) && request.Path.IsMatchingRoute(UiBaseRoute); - } - - private bool IsRequestingAsyncApiUi(HttpRequest request) + private async Task RespondWithAsyncApiHtmlAsync(HttpResponse response, string route) + { + await using var stream = typeof(AsyncApiUiMiddleware).Assembly.GetManifestResourceStream("Saunter.UI.index.html"); + if (stream == null) { - return HttpMethods.IsGet(request.Method) && request.Path.IsMatchingRoute(UiIndexRoute); + response.StatusCode = (int)HttpStatusCode.InternalServerError; + return; } - private string UiIndexRoute => _options.Middleware.UiBaseRoute?.TrimEnd('/') + "/index.html"; + using var reader = new StreamReader(stream); + var indexHtml = new StringBuilder(await reader.ReadToEndAsync()); - private string GetUiIndexFullRoute(HttpRequest request) + // Replace dynamic content such as the AsyncAPI document url + foreach (var replacement in new Dictionary { ["{{title}}"] = _options.Middleware.UiTitle, ["{{asyncApiDocumentUrl}}"] = route, }) { - if (request.PathBase != null) - { - return request.PathBase.Add(UiIndexRoute); - } - - return UiIndexRoute; + indexHtml.Replace(replacement.Key, replacement.Value); } - private string UiBaseRoute => _options.Middleware.UiBaseRoute?.TrimEnd('/') ?? string.Empty; + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = MediaTypeNames.Text.Html; + await response.WriteAsync(indexHtml.ToString(), Encoding.UTF8); + } - private string GetDocumentFullRoute(HttpRequest request) - { - if (request.PathBase != null) - { - return request.PathBase.Add(_options.Middleware.Route); - } + private string GetUiBaseRoute() => _options.Middleware.UiBaseRoute?.TrimEnd('/') ?? string.Empty; - return _options.Middleware.Route; - } - } -} + private string GetUiBaseRouteNamed(string documentName) => GetUiBaseRoute() + .Replace(HttpContextExtensions.UriDocumentPlaceholder, documentName) + .TrimEnd('/'); +} \ No newline at end of file