Monday, March 12, 2018

Adding documentation into ASP.NET Web API

When you provide Web API, there is a question, how to inform a user about all its abilities, about the syntax of requests, etc. Usually, you should create some available Web page, where you discuss these topics. But wouldn't it be great, if the Web API itself provided access to the documentation?


If you open a page of any serious project on GitHub, you'll see well-written Readme.md document. This Markdown document describes the purpose of the repository and frequently contains links to other documents. The great thing here is that GitHub automatically converts these documents in HTML and shows the result to you. It makes Markdown files a good place to store documentation about your project. First of all, they can provide rich formatting. Also, they are stored in the VCS along with your code. It makes these files a first-class citizen. You treat them as a part of your code and modify them when you make modifications to your code. At least it should be in theory. Now you have all your documentation in your repository.

It is a good thing if your repository is opened. But I work for a company, which provides some Web APIs to external clients. These clients do not have access to our code repositories. How should I provide documentation about these services?

I can create a separate site with documentation. But now I have two places where information about my product is stored: in Markdown files and on this site. I can automate the process of creation of the site with documentation to generate it from my Markdown files. Or I can create a separate document (e.g. PDF) containing content of these files.

There is nothing wrong with this approach. But I think we can get one more step in this direction. Why should we separate our API from documentation? Can we provide them in one place? For example, if our Web API is accessible at URL http://www.something.com/api/data then documentation about this API can be accessible at URL http://www.something.com/api/help.md.

How difficult is it to implement such documentation system using ASP.NET Web API? Let's take a look.

I'll start with simple Web API using OWIN. Here is my Startup file:

[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;
        }
    }
}

I'll add some Markdown files to my project:



I want to say a couple of words about these files. First of all, there could be a complex structure of subfolders keeping different parts of my documentation. Next, there are other files here, like images. My Markdown files can reference them. Our solution must support both: tree of folders and additional files.

Let's start with Web.config file. We need to make some modifications to it. You see, Internet Information Services (IIS) can provide static files all by itself. For example, if I'll ask for http://myhost/help/root.md, IIS will understand, that there is such a file on the disk. Then it'll try to return it. It means, that IIS will not pass the request to our application. But this is not what we want. We don't want to return raw Markdown file. We want to convert it to HTML first. This is why we need to modify Web.config. We must instruct IIS, that it should pass all requests to our application. We do it by altering the system.webServer section:

<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>

Now IIS will not process static files. But we still need it (e.g. for images in our documentation). This is why we'll use Microsoft.Owin.StaticFiles NuGet package. Let's say, I want my documentation to be available at path "/api/doc". In this case, I'll configure this package the following way:

[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;
        }
    }
}

Now we can serve static files from "Help" folder of our application by "/api/doc" path. But we still need to somehow convert Markdown files into HTML and serve them. For this purpose, we'll write OWIN middleware. This middleware will use Markdig NuGet package.

[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);
        }
    }
}

Let's take a look, how this middleware works. First of all, it checks if the request was for a Markdown file or not. This is what GetMarkdownFile function do. It tries to find a Markdown file corresponding to a request and returns its FileInfo object if the file is found, or null otherwise. I agree, that my implementation of this function is not the best. It just serves to prove the concept. You can replace it with any other implementation you want.

If the file was not found, our middleware just passes the request further using await next(). But if the file is found, it reads its content, converts it to HTML and returns the response.

Now you have a documentation available for users of your API in several places. It is available in your VCS repository. It is also available directly through your Web API. And your documentation is a part of your code, which is stored under VCS.

This is a great achievement from my point of view. But there is one drawback I'd like to discuss. This system is good if your product is stable. But in the early phase of development, it is not always clear how your API should look like, what is the form of requests and responses, etc. It means that on this phase your documentation should be opened to comments. There must be some system to comment on the content of Markdown files. GitHub has the system of Issues, where you can write comments about the code. As documentation is a part of our code now, we can use Issues to discuss the content of documentation at development phase. But I think it is not the best solution. It would be much better to write comments directly on the document like we can do it in Confluence. Shortly speaking, I think we still need some tool to be able to discuss Markdown documents at an early stage of development.

No comments:

Post a Comment