Using multiple operations in one controller in custom API App & Azure Logic Apps

TL;DR - In my Web API I was using one controller with multiple operations that had the same method names which generated invalid Swagger.

Here is how I found out what the issue was, how I fixed it and how I could use all my operation in Azure Logic Apps.

Last week I was working on an Azure Application Insights connector for Azure Logic Apps that enables you to write traces, custom events, simple metrics and sampled metrics to Azure Application Insights.

Behind the scenes Azure Logic Apps just uses simple Web APIs. If you want to bring your own connector you can simply write your own Web API and host it as an Azure API App with Swagger documentation. And that's what I did:

Initial Swagger

As you can see I have a controller per telemetry type: Events, Metrics and Traces where metrics has two operations.

This is because one can be used to track simple metric with just a value while the other uses a sampling approach where you can provide more granular information such as Min, Max, Sum, etc.

Unfortunately when I select my custom API App in the Logic Apps editor I only see 3 operations:
Loading Initial Swagger in AzureLogic Apps

When I select the metrics operation I can clearly see that it is loading the operation using the sampling approach :

Select Operation

While looking at the code, I was amazed since these are pretty simple operations that are documented quite well :

/// <summary>
/// Tracks a custom metric to Azure Application Insights
/// </summary>
/// <param name="metricMetadata">Metadata concerning the metric to track</param>
[HttpPost]
[Route("metrics")]
[SwaggerResponse(HttpStatusCode.NoContent, description: "Metric was successfully written to Azure Application Insights")]
[SwaggerResponse(HttpStatusCode.BadRequest, description: "Specified metric metadata was invalid")]
public IHttpActionResult Metric([FromBody]Contracts.v1.BasicMetricMetadata metricMetadata)  
{
    // Process requests...
}

/// <summary>
/// Tracks a custom metric to Azure Application Insights
/// </summary>
/// <param name="metricMetadata">Metadata concerning the metric to track</param>
[HttpPost]
[Route("metrics/sampling")]
[SwaggerResponse(HttpStatusCode.NoContent, description: "Metric was successfully written to Azure Application Insights")]
[SwaggerResponse(HttpStatusCode.BadRequest, description: "Specified metric metadata was invalid")]
public IHttpActionResult Metric([FromBody]Contracts.v1.SampledMetricMetadata metricMetadata)  
{
    // Process requests...
}

However, I noticed that the metadata for both operations where identical so I've optimized the descriptions accordingly:

Change Operation Metadata

Unfortunately this didn't fix the problem. While trying to see what was going on I decided to change the order of the operations to see what the impact was in Azure Logic Apps.

Changed Operations Order

As you can see, it now loads the simplified operation and thus it clearly is impacted by the order, somehow.

When I seperate the operations across controllers it also has an impact because then it successfully loads all operations.

Working Version After Seperating Controller

When I have a look at the Swagger UI you'll also see them as seperate sections:

Seperate Across Controllers

Unfortunately, this is not a good fix. Personally I prefer to consolidate similar operations within the same context into one controller. That said, the issue here is clearly related to the Swagger that is being generated.

It's all about the metadata

With the help of Jeff Holan, I found out that the Logic Apps editor uniquely identifies operations based on their OperationId, Path/Route & Method. This is probably the reason why I'm seeing the above behavior.

When you read the Swagger/OpenAPI specification it also clearly mentions that an OperationId should be unique per operation:

Unique string used to identify the operation. The id MUST be unique among all operations described in the API. Tools and libraries MAY use the operationId to uniquely identify an operation, therefore, it is recommended to follow common programming naming conventions.

Let's have a look at our current Swagger:

{
    "swagger": "2.0",
    "info": {
        "version": "v1",
        "title": "Application Insights Connector"
    },
    "host": "localhost:42239",
    "schemes": ["https"],
    "paths": {
        "/api/v1/events": { ... },
        "/api/v1/metrics": {
            "post": {
                "tags": ["Metrics"],
                "summary": "Tracks a custom metric to Azure Application Insights",
                "operationId": "Metrics_Metric",
                "consumes": ["application/json",
                "text/json",
                "application/xml",
                "text/xml",
                "application/x-www-form-urlencoded"],
                "produces": ["application/json",
                "text/json",
                "application/xml",
                "text/xml"],
                "parameters": [ ... ],
                "responses": { ... }
            }
        },
        "/api/v1/metrics/sampling": {
            "post": {
                "tags": ["Metrics"],
                "summary": "Tracks a custom metric with sampling to Azure Application Insights",
                "operationId": "Metrics_Metric",
                "consumes": ["application/json",
                "text/json",
                "application/xml",
                "text/xml",
                "application/x-www-form-urlencoded"],
                "produces": ["application/json",
                "text/json",
                "application/xml",
                "text/xml"],
                "parameters": [ ... ],
                "responses": { ... }
            }
        },
        "/api/v1/traces": { ... }
    },
    "definitions": { ... }
}

This is clearly the issue since both operations have Metrics_Metric as an OperationId and are thus not unique. We can also see that it is a composite key of controller name and method name.

This means that if we change the name of our methods
it should also reflect in the generated Swagger:

{
    "swagger": "2.0",
    "info": {
        "version": "v1",
        "title": "Application Insights Connector"
    },
    "host": "localhost:42239",
    "schemes": ["https"],
    "paths": {
        "/api/v1/events": { ... },
        "/api/v1/metrics": {
            "post": {
                "tags": ["Metrics"],
                "summary": "Tracks a custom metric to Azure Application Insights",
                "operationId": "Metrics_Metric",
                "consumes": ["application/json",
                "text/json",
                "application/xml",
                "text/xml",
                "application/x-www-form-urlencoded"],
                "produces": ["application/json",
                "text/json",
                "application/xml",
                "text/xml"],
                "parameters": [ ... ],
                "responses": { ... }
            }
        },
        "/api/v1/metrics/sampling": {
            "post": {
                "tags": ["Metrics"],
                "summary": "Tracks a custom metric with sampling to Azure Application Insights",
                "operationId": "Metrics_SampledMetric",
                "consumes": ["application/json",
                "text/json",
                "application/xml",
                "text/xml",
                "application/x-www-form-urlencoded"],
                "produces": ["application/json",
                "text/json",
                "application/xml",
                "text/xml"],
                "parameters": [ ... ],
                "responses": { ... }
            }
        },
        "/api/v1/traces": { ... }
    },
    "definitions": { ... }
}

As an alternative you can use also decorate your methods with the SwaggerOperation attribute and provide the name of the operation yourself.

This is the solution I decided to go with as I find this a more explicit approach and even if I rename my method, the operation Id will remain the same.

{
    "swagger": "2.0",
    "info": {
        "version": "v1",
        "title": "Application Insights Connector"
    },
    "host": "localhost:42239",
    "schemes": ["https"],
    "paths": {
        "/api/v1/events": { ... },
        "/api/v1/metrics": {
            "post": {
                "tags": ["Metrics"],
                "summary": "Tracks a custom metric to Azure Application Insights",
                "operationId": "metrics",
                "consumes": ["application/json",
                "text/json",
                "application/xml",
                "text/xml",
                "application/x-www-form-urlencoded"],
                "produces": ["application/json",
                "text/json",
                "application/xml",
                "text/xml"],
                "parameters": [ ... ],
                "responses": { ... }
            }
        },
        "/api/v1/metrics/sampling": {
            "post": {
                "tags": ["Metrics"],
                "summary": "Tracks a custom metric with sampling to Azure Application Insights",
                "operationId": "metrics/sampling",
                "consumes": ["application/json",
                "text/json",
                "application/xml",
                "text/xml",
                "application/x-www-form-urlencoded"],
                "produces": ["application/json",
                "text/json",
                "application/xml",
                "text/xml"],
                "parameters": [ ... ],
                "responses": { ... }
            }
        },
        "/api/v1/traces": { ... }
    },
    "definitions": { ... }
}

Conclusion

It's been a very interesting ride to see how the Swagger is structured and how we should optimize our code to generate a valid and well-structured Swagger.

When the issue was fixed it made total sense that having the same method names was not the best fit. However, I'm more of a fan to decorate these methods with an additional attribute as I find this a more explicit approach that clearly states the name of the operation. The additional benefit is that when I rename my method, the operation Id will remain the same.

Next to that it is great to see how Azure Logic Apps supports Swagger nicely and how easy it is to write your own connector. You can even submit your custom connector so that they can publish it in the connector gallery.

Thanks for reading,

Tom.