We are building an API that contains several controllers with many operations. This API has to be consumed by different client applications.
- Our own management portal
- External integration companies
- A mobile application
While we prefer to keep the code in the same ASP.NET Core Web API project (this is easy for many reasons, of which simplicity may be the biggest one), we don't want to expose every part of functionality to every client.
And this post is describing how we declare our API in such a way that we have multiple logical API specs that we then deploy in Azure API Management as different API's.
The WebAPI project
Enabling nswag in the build action
After creating the new ASP.NET Web API project, we can add the following NSwag
packages to our project.
<PackageReference Include="NSwag.AspNetCore" Version="14.0.3" />
<PackageReference Include="NSwag.MSBuild" Version="14.0.3">
These packages will be used to decorate our operations and to generate the OpenAPI spec at build/deploy time.
We also add a build action to generate the open api spec in our deployment/api
folder.
<Target Name="NSwag" AfterTargets="PostBuildEvent" Condition=" '$(Docker)' != 'yes' ">
<Exec WorkingDirectory="$(ProjectDir)" EnvironmentVariables="ASPNETCORE_ENVIRONMENT=Development;IS_NSWAG_BUILD=true;API_TITLE=Events_Backend_Api;INCLUDE_ALL_OPERATIONS=true" Command="$(NSwagExe_Net80) run nswag-client.json /variables:Configuration=$(Configuration),Output=backend-openapi.json" />
<Exec WorkingDirectory="$(ProjectDir)" EnvironmentVariables="ASPNETCORE_ENVIRONMENT=Development;IS_NSWAG_BUILD=true;API_TITLE=Events_Public_Api;API_TYPE=Public" Command="$(NSwagExe_Net80) run nswag-spec.json /variables:Configuration=$(Configuration),Output=backend-public-openapi.json" />
</Target>
When we build our project, the PostBuildEvent
actions will be executed and the nswag
will be used to use the nswag-spec.json
configuration file to output the open-api as json to the configured output file. (/deployment/api
in our case).
In the above configuration, we are generating two different api specs:
-
backend-openapi.json
: we are passingINCLUDE_ALL_OPERATIONS=true
as the argument to the nswag command. -
backend-public-openapi.json
: we are passingAPI_TYPE=Public
as the argument to the nswag command.
In the next steps, I will show how these arguments are being used to filter out the correct operations in the open API spec generation.
Decorating the operations
In order to filter out the correct operations, I have created a custom Attribute that I can use on controllers and operations to indicate in which API output spec they should be included or not.
The custom attribute looks like this:
[AttributeUsage(AttributeTargets.All)]
public class ApiTypeAttribute : Attribute
{
private readonly string? apiType;
private readonly bool alwaysInclude;
public ApiTypeAttribute(string? apiType = null, bool alwaysInclude = false)
{
this.apiType = apiType;
this.alwaysInclude = alwaysInclude;
}
public string? ApiType => apiType;
public bool AlwaysInclude => alwaysInclude;
}
This attribute can then be used and applied to operations and controllers, like in the following example:
using Events.WebAPI.Models;
using Events.WebAPI.Responses;
using Events.WebAPI.Runtime;
using Events.WebAPI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using NSwag.Annotations;
namespace Events.WebAPI.Controllers;
/// <summary>
/// API endpoint that exposes Events functionality
/// </summary>
[ApiController]
[Route("events")]
[ApiType(apiType: ApiTypes.Admin)]
public class EventController(EventsService eventsService): ControllerBase
{
/// <summary>
/// Get all events
/// </summary>
[HttpGet()]
[ApiType(apiType: ApiTypes.Public)]
[SwaggerResponse(StatusCodes.Status200OK, typeof(EventsResponse), Description = "The available events.")]
public async Task<IActionResult> ListEvents()
{
var events = await eventsService.GetEventsAsync();
return Ok(new EventsResponse(events.ToArray()));
}
/// <summary>
/// Get all events
/// </summary>
[HttpPost()]
[SwaggerResponse(StatusCodes.Status200OK, typeof(void), Description = "The event was created.")]
public async Task<IActionResult> CreateEvent(Event @event)
{
await eventsService.CreateEventAsync(@event);
return Ok();
}
}
In the above controller, you can see that by default, the controller and its operations are considered to be part of the "Admin" API. But the ListEvents
operation is decorated with the [ApiType(apiType: ApiTypes.Public)]
attribute, indicating it's part of the public API as well.
In order to set this up, the Program.cs
file contains the following logic, that is being called at the time of nswag-build.
builder.Services.AddOpenApiDocument(document => { GenerateOpenApiSpec(document, "v1"); });
void GenerateOpenApiSpec(AspNetCoreOpenApiDocumentGeneratorSettings nswagSettings, string documentName)
{
string apiTitle = "Savanh Events API";
bool includeAllOperations = false;
string? apiType = null;
if (!string.IsNullOrEmpty(builder.Configuration["API_TYPE"]))
{
apiType = builder.Configuration["API_TYPE"];
}
if (!string.IsNullOrEmpty(builder.Configuration["INCLUDE_ALL_OPERATIONS"]))
{
_ = bool.TryParse(builder.Configuration["INCLUDE_ALL_OPERATIONS"], out includeAllOperations);
}
if (!string.IsNullOrEmpty(builder.Configuration["API_TITLE"]))
{
apiTitle = builder.Configuration["API_TITLE"];
apiTitle = apiTitle.Replace("_", " ");
}
Console.WriteLine($"Generating api doc : AllOperations: {includeAllOperations} // ApiType: {apiType}");
nswagSettings.OperationProcessors.Add(new OpenApiSpecOperationProcessor(includeAllOperations, apiType));
nswagSettings.DocumentName = documentName;
nswagSettings.Title = apiTitle;
nswagSettings.Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString();
}
In the above code snippet, you can see that the input variables (API_TYPE
, INCLUDE_ALL_OPERATIONS
, API_TITLE
) that are passed in the PostBuild action are being used to apply the behavior for the open API spec generation.
And there is an important line (nswagSettings.OperationProcessors.Add(new OpenApiSpecOperationProcessor(includeAllOperations, apiType));
) that passed those configuration values to an OperationProcessor that will then filter out the operation or not. And that Processor is seen in the following snippet.
public class OpenApiSpecOperationProcessor : IOperationProcessor
{
private readonly bool includeAllOperations;
private readonly string? apiType;
public OpenApiSpecOperationProcessor(bool includeAllOperations = false, string? apiType = null)
{
this.includeAllOperations = includeAllOperations;
this.apiType = apiType;
}
// This method return a boolean, indicating to include the operation or not in the output
public bool Process(OperationProcessorContext context)
{
if (includeAllOperations)
{
return true;
}
// Looking for the method or controller to have the ApiType attribute defined
ApiTypeAttribute? attribute = null;
if (context.MethodInfo.IsDefined(typeof(ApiTypeAttribute), true))
{
// First we check the method (deepest level), as that can override the controller
attribute = (ApiTypeAttribute?)context.MethodInfo.GetCustomAttribute(typeof(ApiTypeAttribute));
}
else
{
// If no attribute on the method, we check the controller
if (context.ControllerType.IsDefined(typeof(ApiTypeAttribute), true))
{
attribute = (ApiTypeAttribute?)context.ControllerType.GetCustomAttribute(typeof(ApiTypeAttribute));
}
}
// Neither the method, nor the controller have the attribute
// So we return false as it should not be included
if (attribute == null)
{
return false;
}
// Since we found an attribute, we are now applying the logic where the method
// will be included in the open api spec, when AlwaysInclude is on,
// or when the ApiType matches the requested api type
if (!string.IsNullOrEmpty(apiType))
{
var include = attribute.AlwaysInclude || apiType.Equals(attribute.ApiType, StringComparison.CurrentCultureIgnoreCase);
return include;
}
return attribute.AlwaysInclude;
}
}
The result is now that, on every build of the WebAPI project, we will have the generated OpenAPI specs (swagger) stored in the corresponding output folder. (which is configured in nswag-spec.json
)
Deploying the WebAPI to Azure API Management
This article leaves out the deployment logic to deploy the actual API as a Docker container and host it in an App Service. That would lead us too far and can be found in the github repo of this article.
We now just focus on importing the OpenAPI specs in Azure API Management as different API's.
This happens by leveraging the following bicep code.
Bicep modules
The following bicep modules can just be reused and then called from within your own project specific bicep file.
api-management-api.bicep
//Parameters
param environmentAcronym string
param locationAcronym string
param apiManagementName string
param serviceUrl string
param apiSpec string
param format string
param displayName string
param description string
param apiRevision string = '1'
param subscriptionRequired bool = true
param path string
param name string
param policy string
param policyFormat string = 'rawxml'
param namedValues array
// Apply naming conventions
var apiName = '${locationAcronym}-${environmentAcronym}-api-${name}'
// Get the existing APIM instance
resource apiManagement 'Microsoft.ApiManagement/service@2021-08-01' existing = {
name: apiManagementName
}
// Define API resource
resource apiManagementApi 'Microsoft.ApiManagement/service/apis@2021-12-01-preview' = {
parent: apiManagement
name: apiName
properties: {
displayName: displayName
description: description
apiRevision: apiRevision
subscriptionRequired: subscriptionRequired
serviceUrl: serviceUrl
path: path
format: format
value: apiSpec
protocols: [
'https'
]
// Changing the subscription header name
subscriptionKeyParameterNames: {
header: 'x-subscription-key'
query: 'x-subscription-key'
}
isCurrent: true
}
dependsOn: apiManagementNamedValues
}
// Specifying the policy on the API level
resource apiManagementapiPolicy 'Microsoft.ApiManagement/service/apis/policies@2021-12-01-preview' = {
parent: apiManagementApi
name: 'policy'
properties: {
value: policy
format: policyFormat
}
}
// Add named values
resource apiManagementNamedValues 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = [for namedValue in namedValues: {
parent: apiManagement
name: '${namedValue.name}'
properties: {
displayName: namedValue.displayName
value: namedValue.value
secret: namedValue.isSecret
}
}]
output apiName string = apiManagementApi.name
output basePath string = path
We can also deploy an API product for every seperate API. That module can be found below.
api-management-product.bicep
param name string
param apimName string
param displayName string
param description string
param subscriptionRequired bool = true
param apiNames array
resource product 'Microsoft.ApiManagement/service/products@2021-01-01-preview' = {
name: '${apimName}/${name}'
properties: {
displayName: displayName
description: description
subscriptionRequired: subscriptionRequired
state: 'published'
}
resource api 'apis@2021-01-01-preview' = [for apiName in apiNames: {
name: apiName
}]
}
And then we can call these modules from our main .bicep
file, as can be seen in the following snippet:
// Deploy actual API
var apiManagementName = '${locationAcronym}-${environmentAcronym}-${companyName}-apim'
// deploy APIs to the API management instance
var backend_api_policy = '''
<policies>
<inbound>
<authentication-managed-identity resource="{0}" />
<base />
</inbound>
</policies>
'''
module backendApi 'modules/api-management-api.bicep' = {
name: 'backendApi'
scope: backendResourceGroup
params: {
locationAcronym: locationAcronym
environmentAcronym: environmentAcronym
apiManagementName: apiManagementName
apiSpec: string(loadJsonContent('../api/backend-openapi.json'))
format: 'openapi+json'
path: 'backend/api/v1'
// The following should typically be taken from output of the actual bicep module for App Service
// But this sample is focusing on the API Management side of things
serviceUrl: 'https://weu-dev-samvhintx-app-backend-api.azurewebsites.net/'
displayName: 'SVH IntX API (${environmentAcronym})'
description: 'The API that provides all required functionality on the ${environmentAcronym} environment.'
name: '${environmentAcronym}-api-backend'
policy: format(backend_api_policy, appServiceClientId)
namedValues: [ ]
}
}
module publicApi 'modules/api-management-api.bicep' = {
name: 'publicApi'
scope: backendResourceGroup
params: {
locationAcronym: locationAcronym
environmentAcronym: environmentAcronym
apiManagementName: apiManagementName
apiSpec: string(loadJsonContent('../api/backend-public-openapi.json'))
format: 'openapi+json'
path: 'public/api/v1'
// The following should typically be taken from output of the actual bicep module for App Service
// But this sample is focusing on the API Management side of things
serviceUrl: 'https://weu-dev-samvhintx-app-backend-api.azurewebsites.net/'
displayName: 'SVH IntX Public API (${environmentAcronym})'
description: 'The API that provides all required functionality on the ${environmentAcronym} environment.'
name: '${environmentAcronym}-api-public'
policy: format(backend_api_policy, appServiceClientId)
namedValues: [ ]
}
}
The result
After our successful deployment, this is the result in the API Management instance, where we have two different API products, each showing a different set of Operations.
This can be seen in the following screenshots.
*The "backend" API exposing two operations. *
The "public" API exposing just one operation.
As always, all code can be found online. Here.
Top comments (0)