Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web API Help Page - method paths and controllers grouping #54

Closed
nomttr opened this issue Nov 8, 2016 · 4 comments
Closed

Web API Help Page - method paths and controllers grouping #54

nomttr opened this issue Nov 8, 2016 · 4 comments
Assignees
Labels

Comments

@nomttr
Copy link

nomttr commented Nov 8, 2016

Hi, let's assume that i have two versions of HiController. I'm versioning my services by using URL path. In the ApiGroup.cshtml file i'm replacing {version} with the value of ApiVersionAttribute in order to get paths like these: GET api/framework/{version}/hello -> GET api/framework/1/hello. In the bellow configuration i'm expecting to get three paths but instead i'm getting only two. Is this a bug or misconfiguration? What's more, do you know how to group HiController and Hi2Controller in the one group "Hi"?

image

Controllers:

    [ApiVersion("1", Deprecated = true)]
    public class HiController : ApiController
    {
        [HttpGet]
        [Route("api/framework/{version:apiVersion}/hello")]
        public string Hi(){ /* ommitted */ }
    }

    [ApiVersion("2")]
    public class Hi2Controller : ApiController
    {
        [HttpGet]
        [Route("api/framework/hello")]
        [Route("api/framework/{version:apiVersion}/hello")]
        public string Hi() { /* ommitted */ }
    }

WebApiConfig

 public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // Web API configuration and services
            // Configure Web API to use only bearer token authentication.
            config.SuppressDefaultHostAuthentication();
            config.Filters.Add(new HostAuthenticationFilter(OAuthDefaults.AuthenticationType));

            config.AddApiVersioning(options =>
            {
                options.ReportApiVersions = true;
                options.ApiVersionSelector = new CurrentImplementationApiVersionSelector(options);
                options.AssumeDefaultVersionWhenUnspecified = true;
            });

            var constraintResolver = new DefaultInlineConstraintResolver()
            {
                ConstraintMap =
                {
                    ["apiVersion"] = typeof( ApiVersionRouteConstraint )
                }
            };
            config.MapHttpAttributeRoutes(constraintResolver);

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

ApiGroup.cshtml

<table class="help-page-table">
    <thead>
        <tr><th>API</th><th>Description</th></tr>
    </thead>
    <tbody>
        @foreach (var api in Model.OrderByDescending(o => o.RelativePath))
        {
            if (api.RelativePath.Contains("{version}"))
            {
                var version = api.ActionDescriptor.ControllerDescriptor.GetDeclaredApiVersions().FirstOrDefault();
                if (version != null)
                {
                    string versionNumber;
                    if (version.MinorVersion != null && version.MinorVersion != 0)
                    {
                        versionNumber = string.Format(version.MajorVersion + "." + version.MinorVersion);
                    }
                    else
                    {
                        versionNumber = version.MajorVersion.ToString();
                    }

                    api.RelativePath = api.RelativePath.Replace("{version}", versionNumber);
                }
            }
            <tr>
                <td class="api-name"><a href="@Url.Action("Api", "Help", new { apiId = api.GetFriendlyId() })">@api.HttpMethod.Method @api.RelativePath</a></td>
                <td class="api-documentation">
                    @if (api.Documentation != null)
                {
                        <p>@api.Documentation</p>
                    }
                    else
                    {
                        <p>No documentation available.</p>
                    }
                </td>
            </tr>
        }
    </tbody>
</table>
@commonsensesoftware
Copy link
Collaborator

You're on the right path. Unfortunately, the model in Web API makes things more complicated than they ought to be for creating API documentation. The design of Web API doesn't support multiple controller types per route and, hence, doesn't directly support enumerating the associated HttpControllerDescriptor and HttpControllerActionDescriptor instances. All is not lost. In order to make things work in Web API, the descriptors themselves are aggregated together in a custom HttpControllerDescriptorGroup. Working with an aggregated HttpControllerDescriptor is a little awkward, but it's the only thing I could come up with that didn't break the model of the Web API design. I provided a pretty detailed explanation of how this information can be used in Issue #46. In that explanation, I would probably go with option 2.

Once you have all the aggregated descriptors, you can aggregate the API version models together. There are built-in extension methods to achieve that. Since the version information is normally only aggregated during route evaluation, this is something you'll have to do yourself for documentation. This is also demonstrated in Issue #46. Using the aggregated API version model, you'll want to enumerate the SupportedApiVersions and DeprecatedApiVersions, if you want things separated; otherwise, you can just use the ImplementedApiVersions. The DeclaredApiVersions aren't useful for controller documentation because it's used for matching the controller types internally for dispatch; that's irrelevant for documentation. You might want to use the DeclaredApiVersions on actions if you have interleaving API versions. That will enable you to select which documentation applies to which version of an action.

The ApiVersion class supports formatting. If you only want the major and minor version number displayed, you can simply use version.ToString( "v" ). The output will match however it was originally defined; for example, "1" or "1.0". It should be noted that "1" and "1.0" are semantically equivalent and will be matched regardless of whether the minor version is specified. This enables you do define "1.0" on your controller, but still have a client send /v1/svc.

Finally, while you can substitute the replacement tokens in the URL template yourself, it might be easier and more reliable to use the UrlHelper class to generate the link for you. You just need to provide the version template parameter, which will enable the UrlHelper to create the link for you. It's really no different than if you were filling in the controller or id tokens.

Hopefully that gets you where you want to go. Expanding the existing documentation support or at least providing end-to-end examples of how to compose the documentation yourself is definitely on the radar. Let me know if you need additional pointers.

@nomttr
Copy link
Author

nomttr commented Nov 9, 2016

I'm not sure if i understand your explanation. Let's say that my controllers structure is grouped by namespace:

-Controller
    -HiController
        -HiController
              [HttpGet]
              [Route("api/framework/{version:apiVersion}/hello")]
              public string Hi() { /*omitted*/ }

        -Hi2Controller
              [HttpGet]
              [Route("api/framework/hello")]
              [Route("api/framework/{version:apiVersion}/hello")]
              public string Hi() { /*omitted*/ }

I did the changes in the WebApiConfig.cs as you explained in #46. Because i want to group my controllers by namespace the WebApiConfig.cs looks as bellow:

public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // Web API configuration and services
            // Configure Web API to use only bearer token authentication.
            config.SuppressDefaultHostAuthentication();
            config.Filters.Add(new HostAuthenticationFilter(OAuthDefaults.AuthenticationType));

            config.AddApiVersioning(options =>
            {
                options.ReportApiVersions = true;
                options.ApiVersionSelector = new CurrentImplementationApiVersionSelector(options);
                options.AssumeDefaultVersionWhenUnspecified = true;
            });

            var constraintResolver = new DefaultInlineConstraintResolver()
            {
                ConstraintMap =
                {
                    ["apiVersion"] = typeof( ApiVersionRouteConstraint )
                }
            };
            config.MapHttpAttributeRoutes(constraintResolver);

            var services = config.Services;
            var assembliesResolver = services.GetAssembliesResolver();
            var typeResolver = services.GetHttpControllerTypeResolver();
            var controllerTypes = typeResolver.GetControllerTypes(assembliesResolver);
            var controllerDescriptors =
                controllerTypes.Select(
                    controllerType => new HttpControllerDescriptor(config, string.Empty, controllerType));

            IDictionary<string, List<HttpControllerDescriptor>> groupedControllerDescriptors = GroupControllersDescriptors(controllerDescriptors);

            var versionMapping =
                groupedControllerDescriptors.ToDictionary(
                                              kvp => kvp.Key,
                                              kvp => kvp.Value.Select(d => d.GetApiVersionModel()).Aggregate());
        }

        private static IDictionary<string, List<HttpControllerDescriptor>> GroupControllersDescriptors(IEnumerable<HttpControllerDescriptor> controllerDescriptors)
        {
            IDictionary<string, List<HttpControllerDescriptor>> groupedControllerDescriptors = new Dictionary<string, List<HttpControllerDescriptor>>();

            foreach (var x in controllerDescriptors)//namespace
            {
                List<HttpControllerDescriptor> namespaceGroupedList = new List<HttpControllerDescriptor>();
                foreach (var y in controllerDescriptors)//controllers within namespace
                {
                    if (x.ControllerType.Namespace == y.ControllerType.Namespace)
                    {
                        namespaceGroupedList.Add(y);
                    }
                }
                if (!groupedControllerDescriptors.ContainsKey(x.ControllerType.Namespace))
                {
                    groupedControllerDescriptors.Add(x.ControllerType.Namespace, namespaceGroupedList);
                }
            }

            return groupedControllerDescriptors;
        }
    }

How can i then achieve the groups aggregated by namespace in the Index.cshtml and why still my model is returning only one route except of two for Hi2Controller?

@commonsensesoftware
Copy link
Collaborator

I guess I missed the tidbits about namespaces. Since the second version was named Hi2Controller, I assumed the controllers were in the same namespaces. If the controllers are in different namespaces, then I would keep the controller type names the same (e.g. HiController). This will make your documentation efforts easier. It seems I steered you slightly the wrong way. If you keep the names the same, then Option 1 from my suggestion will probably work better. The default controller name convention is XXXController, which is why you see two listed items for Hi and Hi2.

I won't pretend that there's no work to be done here. There is no out-of-the-box support for the IApiExplorer (and help page) or Swagger - yet. I can point in you in the right direction and even take feedback, but for now, expect for there to be work on your side. Hopefully, some time in the not too distant future I'll have time or additional community support to provide included support or a complementary library.

Since you're using the Help Page approach, you may want to have a look at the built-in, default implementation of ApiExplorer.cs if you haven't already. You may be able to extend it to suit your needs, but if not, at least it should provide a good foundation of where to start.

The Help Page support does not account for different controller versions. As such, the first page should not list any routes or actions because they could be different between API versions. For example, you might add new routes, remove odd routes, or change some other part of an existing route - like a route parameter. I would suggest the first page only list the controller (e.g. service) names. The first click-through will take you to a details page that breaks things down by API version. Using a drop-down box seems like it would work well there. Based on the API version selected, you can then display the relevant API version-specific details.

Here's how you might start:

using static System.Diagnostics.Debug;

var controllerMapping = Configuration.Services.GetHttpControllerSelector().GetControllerMapping();
var actionSelector = Configuration.Services.GetActionSelector();

foreach ( var entry in controllerMapping )
{
    // flatten controller mapping
    var serviceName = entry.Key;
    var controllerDescriptors = ( entry.Value as IEnumerable<HttpControllerDescriptor> )?.ToArray() ?? new[] { entry.Value };
    var serviceVersionModel = controllerDescriptors.Select( cd => cd.GetApiVersionModel() ).Aggregate();

    WriteLine( serviceName );

    // document controller by api version
    foreach ( var apiVersion in serviceVersionModel.ImplementedApiVersions )
    {
        var controllerDescriptor = controllerDescriptors.First( cd => cd.GetApiVersionModel().DeclaredApiVersions.Contains( apiVersion ) );
        var actionMapping = actionSelector.GetActionMapping( controllerDescriptor );
        var deprecated = controllerDescriptor.GetApiVersionModel().DeprecatedApiVersions.Contains( apiVersion );

        WriteLine( "  Version {0}{1}", apiVersion, deprecated ? " (Deprecated)" : "" );
        WriteLine( "  Actions" );

        // filter actions supported by the current controller
        foreach ( var actionDescriptor in from mapping in actionSelector.GetActionMapping( controllerDescriptor )
                                          from descriptor in mapping
                                          let declaredVersions = descriptor.GetApiVersionModel().DeclaredApiVersions
                                          let implicitlyVersioned = !declaredVersions.Any()
                                          where implicitlyVersioned || declaredVersions.Contains( apiVersion )
                                          select descriptor )
        {
            WriteLine( $"    {actionDescriptor.ActionName}" );
            // TODO: document action parameters
        }
    }
}

Figure 1: Enumerate controllers and actions by API version

This example is based on the By Namespace Web API sample. The output will be:

Agreements
  Version 1.0
  Actions
    Get
  Version 2.0
  Actions
    Get
  Version 3.0
  Actions
    Get

Figure 2: Output from Figure 1 example

Naturally, this is not a complete solution, but hopefully that helps you get closer to your goal.

@nomttr
Copy link
Author

nomttr commented Nov 23, 2016

As you mentioned ApiExplorer.cs turned out to be helpful in this case. I added new class VersionedApiExplorer.cs which inherits from IApiExplorer interface and then replaced default ApiExplorer with my new VersionedApiExplorer in WebApiConfig.cs in the Register method. In several cases it generates more ApiDescriptions than actually exist. In the ApiGroup.cshtml I passed it by distinction apiGroup by GetFriendlyId() method.

VersionedApiExplorer.cs

public class VersionedApiExplorer<TVersionConstraint> : IApiExplorer
    {
        private readonly IApiExplorer _innerApiExplorer;
        private readonly HttpConfiguration _configuration;
        private readonly Lazy<Collection<ApiDescription>> _apiDescriptions;
        private MethodInfo _apiDescriptionPopulator;

        public VersionedApiExplorer(IApiExplorer apiExplorer, HttpConfiguration configuration)
        {
            _innerApiExplorer = apiExplorer;
            _configuration = configuration;
            _apiDescriptions = new Lazy<Collection<ApiDescription>>(Init);
        }

        public Collection<ApiDescription> ApiDescriptions
        {
            get { return _apiDescriptions.Value; }
        }

        private Collection<ApiDescription> Init()
        {
            var descriptions = _innerApiExplorer.ApiDescriptions;

            var controllerSelector = _configuration.Services.GetHttpControllerSelector();
            var controllerMappings = controllerSelector.GetControllerMapping();

            var flatRoutes = FlattenRoutes(_configuration.Routes);
            var result = new Collection<ApiDescription>();

            foreach (var description in descriptions)
            {
                result.Add(description);

                if (controllerMappings != null && description.Route.Constraints.Any(c => c.Value is TVersionConstraint))
                {
                    var matchingRoutes = flatRoutes.Where(r => r.RouteTemplate == description.Route.RouteTemplate && r != description.Route);

                    foreach (var route in matchingRoutes)
                        GetRouteDescriptions(route, result);
                }
            }

            return result;
        }

        private void GetRouteDescriptions(IHttpRoute route, Collection<ApiDescription> apiDescriptions)
        {
            var actionDescriptor = route.DataTokens["actions"] as IEnumerable<HttpActionDescriptor>;

            if (actionDescriptor != null && actionDescriptor.Count() > 0)
                GetPopulateMethod().Invoke(_innerApiExplorer, new object[] { actionDescriptor.First(), route, route.RouteTemplate, apiDescriptions });
        }

        private MethodInfo GetPopulateMethod()
        {
            if (_apiDescriptionPopulator == null)
                _apiDescriptionPopulator = _innerApiExplorer.GetType().GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).FirstOrDefault(
                   m => m.Name == "PopulateActionDescriptions" && m.GetParameters().Length == 4);

            return _apiDescriptionPopulator;
        }

        public static IEnumerable<IHttpRoute> FlattenRoutes(IEnumerable<IHttpRoute> routes)
        {
            foreach (var route in routes)
            {
                if (route is HttpRoute)
                    yield return route;

                var subRoutes = route as IReadOnlyCollection<IHttpRoute>;
                if (subRoutes != null)
                    foreach (IHttpRoute subRoute in FlattenRoutes(subRoutes))
                        yield return subRoute;
            }
        }
    }

WebApiConfig.cs

...
IApiExplorer apiExplorer = config.Services.GetApiExplorer();
            config.Services.Replace(typeof(IApiExplorer), new VersionedApiExplorer<ApiVersionRouteConstraint>(apiExplorer, config));
...

ApiGroup.cshtml

@model IGrouping<HttpControllerDescriptor, ApiDescription>

@{
    var controllerDocumentation = ViewBag.DocumentationProvider != null ?
        ViewBag.DocumentationProvider.GetDocumentation(Model.Key) :
        null;
}

<h2 id="@Model.Key.ControllerName">@Model.Key.ControllerName</h2>
@if (!String.IsNullOrEmpty(controllerDocumentation))
{
    <p>@controllerDocumentation</p>
}
<table class="help-page-table">
    <thead>
        <tr><th>API</th><th>Description</th></tr>
    </thead>
    <tbody>
        @{
            IEnumerable<ApiDescription> apiGroup = Model.ToList().DistinctBy(d => d.GetFriendlyId());

            foreach (var apiDescription in apiGroup.OrderByDescending(r => r.RelativePath))
            {
                <tr>
                    <td class="api-name"><a href="@Url.Action("Api", "Help", new {apiId = apiDescription.GetFriendlyId()})">@apiDescription.HttpMethod.Method @apiDescription.RelativePath</a></td>
                    <td class="api-documentation">
                        @if (apiDescription.Documentation != null || !string.IsNullOrWhiteSpace(apiDescription.Documentation))
                        {
                            <p>@apiDescription.Documentation</p>
                        }
                        else
                        {
                            <p>No documentation available.</p>
                        }
                    </td>
                </tr>
            }
        }
    </tbody>
</table>

@nomttr nomttr closed this as completed Nov 23, 2016
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants