Поддержка документации в ASP.NET Web API +6


Когда вы предоставляете ваш сервис в виде Web API, встает вопрос о том, как проинформировать пользователя о его возможностях, о синтаксисе его запросов и т.п. Обычно вам приходится создавать отдельные Web-страницы, где вы расскрываете эти темы. Но не было бы лучше, если бы сам ваш Web API обеспечивал доступ к своей документации?

Если вы откроете страницу какого-нибудь серьезного проекта на GitHub, вы увидите хорошо оформленный Readme.md. Этот Markdown-документ описывает цель кода, хранящегося в репозитории, и часто содержит ссылки на другие документы. GitHub автоматически конвертирует Markdown в HTML-представление и показывает вам результат в удобной для чтения форме. Это делает Markdown-файлы удобным способом хранения документации о вашем проекте. Прежде всего, этот формат дает достаточно богатые возможности по форматированию текста. Кроме того, данные файлы хранятся в вашей системе контроля версий (VCS) вместе с вашим кодом. Это делает такие документы равноправными с самими файлами кода (first-class citizens). Вы рассматриваете их как часть кода и изменяете их, когда вносите модификации в код. По крайней мере, так должно быть в теории. Теперь у вас есть вся документация в вашем репозитории.

Если ваш репозиторий является открытым, то все замечательно. Пользователи вашего API могут увидеть документацию там. Но я работаю в компании, которая предоставляет некоторые Web API внешним клиентам. Эти клиенты не имеют доступа к нашим репозиториям. Как нам предоставить им документацию по нашим сервисам?

Можно создать отдельный сайт с документацией. Но тогда у нас будет 2 места, где хранится информация о продукте: в Markdown-файлах и на этом сайте. Можно, конечно, автоматизировать процесс создания сайта с документацией, генерируя его из Markdown-документов. Или же можно создавать отдельный документ (например, PDF), включающий в себя содержимое всех этих файлов.

В этом подходе нет ничего плохого. Но, я думаю, что можно сделать еще один шаг в этом направлении. Зачем мы отделяем документацию от самого API? Нельзя ли поставлять их вместе? Например, наш Web API доступен по адресу www.something.com/api/data, а его документация доступна по адресу www.something.com/api/help.md

Насколько трудно реализовать этот подход в ASP.NET Web API? Давайте посмотрим.

Начнем с простого Web API, основанного на OWIN. Вот мой Startup файл:

[assembly: OwinStartup(typeof(OwinMarkdown.Startup))]

namespace OwinMarkdown
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            HttpConfiguration config = new HttpConfiguration();

            config.Formatters.Clear();
            config.Formatters.Add(
                new JsonMediaTypeFormatter
                {
                    SerializerSettings = GetJsonSerializerSettings()
                });

            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new {id = RouteParameter.Optional}
            );

            app.UseWebApi(config);
        }
        
        private static JsonSerializerSettings GetJsonSerializerSettings()
        {
            var settings = new JsonSerializerSettings();

            settings.Converters.Add(new StringEnumConverter { CamelCaseText = false });

            settings.ContractResolver = new CamelCasePropertyNamesContractResolver();

            return settings;
        }
    }
}

Давайте добавим несколько Markdown-файлов в проект:



Следует сказать несколько слов о добавленных файлах. Прежде всего, у нас может быть сложная структура подпапок, хранящая различные части нашей документации. Кроме того, у нас есть и другие файлы, не только Markdown. Например, наша документация может содержать картинки, на которые Markdown-документы будут ссылаться. Поэтому наше решение для предоставления документации через Web API должно поддерживать как структуру папок, так и дополнительные файлы.

Мы начнем изменения с файла Web.config. В него нужно внести некоторые модификации. Дело в том, что Internet Information Services (IIS) может доставлять пользователю статические файлы сам, без участия нашего приложения. Например, если пользователь запросит myhost/help/root.md, IIS поймет, что на диске есть такой файл, и вернет его сам. Это означает, что IIS не передаст запрос в наше приложение. Но это не то, что мы хотим. Нам не нужно возвращать необработанный Markdown-файл, мы хотим сначала преобразовать его в HTML. Именно для этого нам нужно внести изменения в Web.config. Нужно указать IIS, что все запросы нужно передавать нашему приложению, не пытаясь выполнить их самостоятельно. Это можно сделать, настроив секцию system.webServer:

<system.webServer>
  <modules runAllManagedModulesForAllRequests="true" />
  <handlers>
    <remove name="ExtensionlessUrlHandler-Integrated-4.0" />
    <remove name="OPTIONSVerbHandler" />
    <remove name="TRACEVerbHandler" />
    <add name="Owin" verb="" path="*" type="Microsoft.Owin.Host.SystemWeb.OwinHttpHandler, Microsoft.Owin.Host.SystemWeb" />
  </handlers>
</system.webServer>

Теперь IIS не будет обрабатывать статические файлы. Но мы все еще должны поставлять их (например, для картинок в нашей документации). Поэтому мы будем использовать NuGet-пакет Microsoft.Owin.StaticFiles. Так, если мы хотим, чтобы наша документация была доступна по адресу /api/doc, мы должны сконфигурировать этот пакет следующим образом:

[assembly: OwinStartup(typeof(OwinMarkdown.Startup))]

namespace OwinMarkdown
{
    public class Startup
    {
        private static readonly string HelpUrlPart = "/api/doc";

        public void Configuration(IAppBuilder app)
        {
            var basePath = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;

            app.UseStaticFiles(new StaticFileOptions
            {
                RequestPath = new PathString(HelpUrlPart),
                FileSystem = new PhysicalFileSystem(Path.Combine(basePath, "Help"))
            });

            HttpConfiguration config = new HttpConfiguration();

            config.Formatters.Clear();
            config.Formatters.Add(
                new JsonMediaTypeFormatter
                {
                    SerializerSettings = GetJsonSerializerSettings()
                });

            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new {id = RouteParameter.Optional}
            );

            app.UseWebApi(config);
        }
        
        private static JsonSerializerSettings GetJsonSerializerSettings()
        {
            var settings = new JsonSerializerSettings();

            settings.Converters.Add(new StringEnumConverter { CamelCaseText = false });

            settings.ContractResolver = new CamelCasePropertyNamesContractResolver();

            return settings;
        }
    }
}

Теперь мы возвращаем статические файлы из папки Help нашего приложения по адресу /api/doc. Но нам все еще нужно как-то конвертировать Markdown-документы в HTML перед тем, как возвращать их. Для этой цели мы напишем свое OWIN middleware. Это middleware будет использовать NuGet-пакет Markdig.

[assembly: OwinStartup(typeof(OwinMarkdown.Startup))]

namespace OwinMarkdown
{
    public class Startup
    {
        private static readonly string HelpUrlPart = "/api/doc";

        public void Configuration(IAppBuilder app)
        {
            var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();

            app.Use(async (context, next) =>
            {
                var markDownFile = GetMarkdownFile(context.Request.Path.ToString());

                if (markDownFile == null)
                {
                    await next();

                    return;
                }

                using (var reader = markDownFile.OpenText())
                {
                    context.Response.ContentType = @"text/html";

                    var fileContent = reader.ReadToEnd();

                    fileContent = Markdown.ToHtml(fileContent, pipeline);

                    // Send our modified content to the response body.
                    await context.Response.WriteAsync(fileContent);
                }

            });

            var basePath = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;

            app.UseStaticFiles(new StaticFileOptions
            {
                RequestPath = new PathString(HelpUrlPart),
                FileSystem = new PhysicalFileSystem(Path.Combine(basePath, "Help"))
            });

            HttpConfiguration config = new HttpConfiguration();

            config.Formatters.Clear();
            config.Formatters.Add(
                new JsonMediaTypeFormatter
                {
                    SerializerSettings = GetJsonSerializerSettings()
                });

            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new {id = RouteParameter.Optional}
            );

            app.UseWebApi(config);
        }
        
        private static JsonSerializerSettings GetJsonSerializerSettings()
        {
            var settings = new JsonSerializerSettings();

            settings.Converters.Add(new StringEnumConverter { CamelCaseText = false });

            settings.ContractResolver = new CamelCasePropertyNamesContractResolver();

            return settings;
        }

        private static FileInfo GetMarkdownFile(string path)
        {
            if (Path.GetExtension(path) != ".md")
                return null;

            var basePath = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
            var helpPath = Path.Combine(basePath, "Help");

            var helpPosition = path.IndexOf(HelpUrlPart + "/", StringComparison.OrdinalIgnoreCase);
            if (helpPosition < 0)
                return null;

            var markDownPathPart = path.Substring(helpPosition + HelpUrlPart.Length + 1);
            var markDownFilePath = Path.Combine(helpPath, markDownPathPart);
            if (!File.Exists(markDownFilePath))
                return null;

            return new FileInfo(markDownFilePath);
        }
    }
}

Давайте посмотрим, как это middleware работает. Прежде всего, оно проверяет, был ли запрошен Markdown-файл, или что-то другое. Этим занимается функция GetMarkdownFile. Она пытается найти Markdown-файл, соответствующий запросу, и возвращает его FileInfo, если файл найден, или null, если он не найден. Реализация не самая лучшая, но она служит для проверки идеи. Ее можно заменить на любую другую реализацию.

Если файл не был найден, middleware передает обработку запроса дальше, используя await next(). Но если файл найден, то его содержимое читается, преобразуется в HTML и возвращается в ответе.

Теперь мы имеем документацию, которую пользователь может увидеть в нескольких местах. Ее можно просмотреть в VCS repository (например, в GitHub). Она так же доступна непосредственно через наш Web API. И кроме того, документация является частью нашего кода, которую мы храним под VCS.

Я считаю, что это очень хороший результат. Однако следует обсудить и его недостатки.

Прежде всего, такая система хороша, если ваш продукт уже стабилен. Но на ранних стадиях разработки не всегда ясно, как должен выглядеть ваш API, какой формат должны иметь запросы и ответы, и т.д. На этой стадии документация должна быть открыта для комментариев. Поэтому потребуется некий инструмент, позволяющий комментировать содержимое Markdown-файлов. GitHub имеет систему Issues, где вы можете оставлять ваши замечания о коде. Поскольку документация теперь является частью кода, можно использовать Issues для обсуждения ее содержимого на этапе разработки. Но лично я считаю, что это не лучшее решение. Намного удобнее было бы писать комментарии непосредственно в документе, как это возможно сделать в Confluence. Короче говоря, я полагаю, что необходим хороший инструмент для обсуждения Markdown-документов на ранних стадиях разработки.

Мои коллеги из компании Confirmit указали еще на несколько недостатков описанного решения, о которых я должен упомянуть. Доставка документации вместе с API может отрицательно сказаться на быстродействии вашего сервиса, поскольку теперь один ThreadPool будет использоваться и для обслуживания запросов к самому Web API, и для запросов к документации.

Кроме того, добавление точки доступа к документации расширяет поверхность атаки на ваш сервис. Вам нужно решить, предоставляете ли вы доступ к документации только авторизованным пользователям, или любому. В последнем случае открывается возможность для DoS атаки на точку доступа к документации. А поскольку документация поставляется тем же сервисом, что и Web API, это может негативно сказаться и на работе самого API.

На этом я завершаю статью. Надеюсь, изложенная в ней идея будет полезна хотя бы как отправная точка для дальнейших изысканий.




К сожалению, не доступен сервер mySQL