OpenApiMetadata
Summary
With the current implementation of OpenApiMetadata, the method of delivering the swagger documents utilizes an IHttpHandler
. With ASP.NET Core, this has been removed, and is replaced by implementing custom middle ware.
Challenges
The challenges we face with OpenApiMetadata, is how do we serve the swagger document, and how do we address routing for the custom documents. Routing has been solved using see Routing. The swagger document is still built using the same mechanism as before but the serving of the document, the recommended approach is to use ASP.NET Middleware.Â
Solution
First, a new document provider is implemented that abstracts the lookup from the logic to strip the route and lookup against the cache.
#if NETCOREAPP using Microsoft.AspNetCore.Http; namespace EdFi.Ods.Features.OpenApiMetadata.Providers { public interface IOpenApiMetadataDocumentProvider { bool TryGetSwaggerDocument(HttpRequest request, out string document); } } #endif
#if NETCOREAPP using System; using System.Collections.Generic; using System.Linq; using EdFi.Ods.Api.Common.Configuration; using EdFi.Ods.Api.Common.Models; using EdFi.Ods.Api.Common.Providers; using EdFi.Ods.Api.NetCore.Extensions; using EdFi.Ods.Api.NetCore.Routing; using EdFi.Ods.Common; using EdFi.Ods.Common.Extensions; using EdFi.Ods.Features.OpenApiMetadata.Models; using log4net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; namespace EdFi.Ods.Features.OpenApiMetadata.Providers { public class EnabledOpenApiMetadataDocumentProvider : IOpenApiMetadataDocumentProvider { private readonly ILog _logger = LogManager.GetLogger(typeof(EnabledOpenApiMetadataDocumentProvider)); private readonly IOpenApiMetadataCacheProvider _openApiMetadataCacheProvider; private readonly IList<IOpenApiMetadataRouteInformation> _routeInformations; private readonly bool _useReverseProxyHeaders; private readonly Lazy<IReadOnlyList<SchemaNameMap>> _schemaNameMaps; public EnabledOpenApiMetadataDocumentProvider( IOpenApiMetadataCacheProvider openApiMetadataCacheProvider, IList<IOpenApiMetadataRouteInformation> routeInformations, ISchemaNameMapProvider schemaNameMapProvider, ApiSettings apiSettings) { _openApiMetadataCacheProvider = openApiMetadataCacheProvider; _routeInformations = routeInformations; _useReverseProxyHeaders = apiSettings.UseReverseProxyHeaders.HasValue && apiSettings.UseReverseProxyHeaders.Value; _schemaNameMaps = new Lazy<IReadOnlyList<SchemaNameMap>>(schemaNameMapProvider.GetSchemaNameMaps); } public bool TryGetSwaggerDocument(HttpRequest request, out string document) { document = null; var openApiMetadataRequest = CreateOpenApiMetadataRequest(request.Path); var openApiContent = _openApiMetadataCacheProvider.GetOpenApiContentByFeedName(openApiMetadataRequest.GetFeedName()); if (openApiContent == null) { _logger.Debug($"Unable to locate swagger document for {openApiMetadataRequest.GetFeedName()}"); return false; } document = GetMetadataForContent(openApiContent, request); return true; } private string GetMetadataForContent(OpenApiContent content, HttpRequest request) { string basePath = request.PathBase.Value.EnsureSuffixApplied("/") + content.BasePath; return content.Metadata .Replace("%HOST%", Host()) .Replace("%TOKEN_URL%", TokenUrl()) .Replace("%BASE_PATH%", basePath); string TokenUrl() => $"{request.RootUrl(_useReverseProxyHeaders)}/oauth/token"; string Host() => $"{request.Host(_useReverseProxyHeaders)}:{request.Port(_useReverseProxyHeaders)}"; } private OpenApiMetadataRequest CreateOpenApiMetadataRequest(string path) { // need to build the request model manually as binding does not exist in the middleware pipeline. // this is less effort that rewriting the open api metadata cache. var openApiMetadataRequest = new OpenApiMetadataRequest(); var matcher = new RouteMatcher(); foreach (var routeInformation in _routeInformations) { string routeTemplate = routeInformation.GetRouteInformation() .Template; if (matcher.TryMatch(routeTemplate, path, out RouteValueDictionary values)) { if (values.ContainsKey("document")) { // the route for resources/descriptors is the same format as the schema endpoint. // we need to validate that it is a schema instead. string documentName = values["document"] .ToString(); if (_schemaNameMaps.Value.Any(x => x.UriSegment.EqualsIgnoreCase(documentName))) { openApiMetadataRequest.SchemaName = documentName; } if (documentName.EqualsIgnoreCase("resources") || documentName.EqualsIgnoreCase("descriptors")) { openApiMetadataRequest.ResourceType = documentName; } } if (values.ContainsKey("schoolYearFromRoute")) { string schoolYear = values["schoolYearFromRoute"] .ToString(); if (int.TryParse(schoolYear, out int schoolYearFromRoute)) { openApiMetadataRequest.SchoolYearFromRoute = schoolYearFromRoute; } } if (values.ContainsKey("organizationCode")) { openApiMetadataRequest.SchemaName = values["organizationCode"] .ToString(); } if (values.ContainsKey("compositeCategoryName")) { openApiMetadataRequest.CompositeCategoryName = values["compositeCategoryName"] .ToString(); } if (values.ContainsKey("other")) { openApiMetadataRequest.OtherName = values["other"] .ToString(); } } } return openApiMetadataRequest; } } } #endif
Also implemented is a disabled document provider to solve disabling of OpenApiMetadata.
#if NETCOREAPP using Microsoft.AspNetCore.Http; namespace EdFi.Ods.Features.OpenApiMetadata.Providers { public class DisabledOpenApiMetadataDocumentProvider : IOpenApiMetadataDocumentProvider { public bool TryGetSwaggerDocument(HttpRequest request, out string document) { document = null; return false; } } } #endif
A new class RouteMatcher is implement so we can parse the route using the patterns defined by a route (e.g. IOpenApiRouteInformation).
#if NETCOREAPP using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Template; namespace EdFi.Ods.Api.NetCore.Routing { public class RouteMatcher { public bool TryMatch(string routeTemplate, string requestPath, out RouteValueDictionary values) { var template = TemplateParser.Parse(routeTemplate); var routeValueDictionary = GetDefaults(template); var matcher = new TemplateMatcher(template, routeValueDictionary); if (matcher.TryMatch(requestPath, routeValueDictionary)) { values = routeValueDictionary; return true; } values = null; return false; } // This method extracts the default argument values from the template. private RouteValueDictionary GetDefaults(RouteTemplate parsedTemplate) { var result = new RouteValueDictionary(); foreach (var parameter in parsedTemplate.Parameters) { if (parameter.DefaultValue != null) { result.Add(parameter.Name, parameter.DefaultValue); } } return result; } } } #endif
Middleware is then implemented to serve the swagger document as follows:
#if NETCOREAPP using System.Threading.Tasks; using EdFi.Ods.Api.Common.Constants; using EdFi.Ods.Api.NetCore.Extensions; using EdFi.Ods.Common.Extensions; using EdFi.Ods.Common.Security.Helpers; using EdFi.Ods.Features.OpenApiMetadata.Providers; using log4net; using Microsoft.AspNetCore.Http; namespace EdFi.Ods.Features.Middleware { public class OpenApiMetadataMiddleware : IMiddleware { private readonly IOpenApiMetadataDocumentProvider _metadataDocumentProvider; private readonly ILog _logger = LogManager.GetLogger(typeof(OpenApiMetadataMiddleware)); public OpenApiMetadataMiddleware(IOpenApiMetadataDocumentProvider metadataDocumentProvider) { _metadataDocumentProvider = metadataDocumentProvider; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) { // if the request is not a get or ends with swagger.json we abort and move to the next pipeline step if (context.Request.Method != HttpMethods.Get || !context.Request.Path.ToString().EndsWithIgnoreCase("swagger.json")) { await next(context); return; } if (!_metadataDocumentProvider.TryGetSwaggerDocument(context.Request, out string document)) { await next(context); return; } if (!string.IsNullOrEmpty(document)) { var etag = HashHelper.GetSha256Hash(document) .ToHexString() .DoubleQuoted(); if (context.Request.TryGetRequestHeader(HeaderConstants.IfNoneMatch, out string headerValue)) { if (headerValue.EqualsIgnoreCase(etag)) { _logger.Debug($"swagger document was not modified"); context.Response.StatusCode = StatusCodes.Status304NotModified; context.Response.ContentType = GetContentType(); return; } } else { context.Response.Headers[HeaderConstants.ETag] = etag; context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = GetContentType(); await context.Response.WriteAsync(document); } } else { context.Response.StatusCode = StatusCodes.Status404NotFound; } string GetContentType() => "application/json"; } } } #endif
These components are registered into the container as follows if OpenApiMetadata is enabled.
#if NETCOREAPP using Autofac; using EdFi.Ods.Api.Common.Configuration; using EdFi.Ods.Api.Common.Constants; using EdFi.Ods.Api.Common.Container; using EdFi.Ods.Api.Common.ExternalTasks; using EdFi.Ods.Api.Common.Providers; using EdFi.Ods.Api.NetCore.Providers; using EdFi.Ods.Api.NetCore.Routing; using EdFi.Ods.Features.Middleware; using EdFi.Ods.Features.OpenApiMetadata; using EdFi.Ods.Features.OpenApiMetadata.Providers; using EdFi.Ods.Features.RouteInformations; using Microsoft.AspNetCore.Http; namespace EdFi.Ods.Features.Container.Modules { public class EnabledOpenApiMetadataModule : ConditionalModule { public EnabledOpenApiMetadataModule(ApiSettings apiSettings) : base(apiSettings, nameof(EnabledOpenApiMetadataModule)) { } public override bool IsSelected() => IsFeatureEnabled(ApiFeature.OpenApiMetadata); public override void ApplyConfigurationSpecificRegistrations(ContainerBuilder builder) { builder.RegisterType<OpenApiMetadataCacheProvider>() .As<IOpenApiMetadataCacheProvider>() .SingleInstance(); builder.RegisterType<InitializeOpenApiMetadataCache>() .As<IExternalTask>() .SingleInstance(); builder.RegisterType<AllOpenApiMetadataRouteInformation>() .As<IOpenApiMetadataRouteInformation>() .SingleInstance(); builder.RegisterType<ResourceTypeOpenMetadataRouteInformation>() .As<IOpenApiMetadataRouteInformation>() .SingleInstance(); // this is required to for ed-fi default builder.RegisterType<SchemaOpenApiMetadataRouteInformation>() .As<IOpenApiMetadataRouteInformation>() .SingleInstance(); builder.RegisterType<EdFiOpenApiContentProvider>() .As<IOpenApiContentProvider>(); builder.RegisterType<EnabledOpenApiMetadataDocumentProvider>() .As<IOpenApiMetadataDocumentProvider>(); builder.RegisterType<OpenApiMetadataMiddleware>() .As<IMiddleware>() .AsSelf(); } } } #endif
or if OpenApiMetadata is disabled:
#if NETCOREAPP using Autofac; using EdFi.Ods.Api.Common.Configuration; using EdFi.Ods.Api.Common.Constants; using EdFi.Ods.Api.Common.Container; using EdFi.Ods.Api.Common.Providers; using EdFi.Ods.Features.OpenApiMetadata; using EdFi.Ods.Features.OpenApiMetadata.Providers; using Microsoft.AspNetCore.Mvc.ApplicationModels; namespace EdFi.Ods.Features.Container.Modules { public class DisabledOpenApiMetadataModule : ConditionalModule { public DisabledOpenApiMetadataModule(ApiSettings apiSettings) : base(apiSettings, nameof(DisabledOpenApiMetadataModule)) { } public override bool IsSelected() => !IsFeatureEnabled(ApiFeature.OpenApiMetadata); public override void ApplyConfigurationSpecificRegistrations(ContainerBuilder builder) { builder.RegisterType<OpenApiMetadataCacheProvider>() .As<IOpenApiMetadataCacheProvider>(); builder.RegisterType<EdFiOpenApiContentProvider>() .As<IOpenApiContentProvider>(); } } } #endif
Finally we configure middleware within the Startup class using an extension method
using EdFi.Ods.Features.Middleware; using Microsoft.AspNetCore.Builder; namespace EdFi.Ods.WebApi.NetCore { public static class ApplicationBuilderExtensions { public static IApplicationBuilder UseOpenApiMetadata(this IApplicationBuilder builder) => builder.UseMiddleware<OpenApiMetadataMiddleware>(); } }
Note, this extension method resolves the OpenApiMetadataMiddleWare from the container.
// Serves Open API Metadata json files when enabled. if (ApiSettings.IsFeatureEnabled(ApiFeature.OpenApiMetadata.GetConfigKeyName())) { app.UseOpenApiMetadata(); }