.NET Steps – REST API Versioning

Introduction

Did you ever find yourself in a situation where you need to fetch something from external API but the response that you are getting is not the response that is written in the notepad – oh, I mean “the docs“? If you did, I know that the next thing crossed your mind was: those fucking morons. Then you made sure that everyone in your office is aware of the shit you have to deal with.

If you are building an API and want to avoid the fury of developers, keep your documentation up to date, with, of course, a minimum amount of effort.

What is an API?

An API (application programming interface) is a contract between two systems. It defines how two systems communicate using requests and responses. It is usually explained in terms of client and server, where client is an application that sends an request and server is an application that sends the response.

When we are talking about API, we usually refer to WEB APIAPIs that are mostly accessed via HTTP.

What is an REST API?

A REST API is an API that conforms to the design principles of the REST (representational state transfer) architectural style:

  • Uniform interface
  • Client-server decoupling
  • Statelessness
  • Cacheability
  • Layered system architecture
  • Code on demand (optional)

In short, if you want to make an REST API, you need to follow aformentioned rules.

What is an REST API Versioning

A REST API Versioning is a practice of transparently managing changes to your REST API. This can be done with:

  • Path versioning – http://www.example.com/v1/products
  • Query Params – http://www.example.com/api/products?version=1
  • Header – curl -H “Accepts-version: 1.0” http://www.example.com/api/products
  • Versioning through content negotiation – curl -H “Accept: application/vnd.xm.device+json; version=1” http://www.example.com/api/products

I prefer the first one.

Changes to a server (API) can be

  • breaking – change will break clients
    • changing your url path
    • changing request model
    • changing response model
    • deleting endpoints
    • etc.
  • non-breaking – change will not affect clients
    • adding new endpoints
    • bug fixes
    • etc.
What is an OpenApi Specification?

An OpenAPI Specification defines a standard, language-agnostic interface to REST APIs which allows both humans and computers to discover and understand the capabilities of the service without access to source code, documentation, or through network traffic inspection. If you want someone to use your API, just give them OpenAPI Specification, or in our case, a link to it.

Implementation

We are going to build one resource named Examples that has 2 version.

Let’s take a look at our project structure:

  • Controllers
    • V1
      • Models
        • Examples
          • GetExampleQueryDto.cs
          • GetExamplesQueryDto.cs
          • CreateExampleCommandDto.cs
          • UpdateExampleCommandDto.cs
      • ExamplesController.cs
    • V2
      • Models
        • Examples
          • GetExampleQueryDto.cs
          • GetExamplesQueryDto.cs
          • CreateExampleCommandDto.cs
          • UpdateExampleCommandDto.cs
      • ExamplesController.cs
    • ApiControlerBase.cs
    • IApiDto.cs
  • Internal
    • Configuration
      • Rest
        • ServiceCollectionExtensions.cs
        • SlugifyParameterTransformer.cs
    • Swagger
        • ApplicationBuilderExtensions.cs
        • ConfigureSwaggerOptions.cs
        • ServiceCollectionExtensions.cs
        • SwaggerDefaultValues.cs
        • SwaggerInfo.cs
    • Constants
      • ApiTags.cs
      • ApiVersions.cs
    • Examples
      • V1
        • Example
          • CreateExampleCommandDtoExample.cs
          • GetExampleQueryDtoExample.cs
          • GetExamplesQueryDtoExample.cs
          • UpdateExampleCommandDtoExample.cs
      • V2
        • Example
          • CreateExampleCommandDtoExample.cs
          • GetExampleQueryDtoExample.cs
          • GetExamplesQueryDtoExample.cs
          • UpdateExampleCommandDtoExample.cs
    • Mappings
      • PresentationMappingProfile.cs
      • PresentationMappingProfileV2.cs – yes, yes, I know
  • DependencyInjection.cs

Lets take a look at our dependencies

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <PropertyGroup>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>1591</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
    <PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.4.2" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.7" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.0.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
    <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.4.0" />
    <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.3" />
    <PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.4.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\Application\Company.Shorts.Application\Company.Shorts.Application.csproj" />
   
  </ItemGroup>

</Project>

Let’s set up our versioning. Take a look at ServiceCollectionExtensions.cs. Here, we are interested in 2 methods: AddApiVersioning and AddVersionedApiExplorer.

namespace Company.Shorts.Presentation.Api.Internal.Configuration.Rest
{
    using Company.Shorts.Blocks.Common.Exceptions;
    using Hellang.Middleware.ProblemDetails;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.ApplicationModels;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using System;
    using System.Linq;
    using System.Reflection;

    public static class ServiceCollectionExtensions
    {
        public static IServiceCollection AddRestApiConfiguration(
            this IServiceCollection services,
            IHostEnvironment enviroment)
        {
            Type[] knownExceptionTypes = new Type[] { typeof(ValidationException), typeof(NotFoundException) };

            services
                .AddRouting(options =>
                {
                    options.LowercaseUrls = true;
                })
                .AddApiVersioning(options =>
                {
                    options.DefaultApiVersion = new ApiVersion(1, 0);
                    options.AssumeDefaultVersionWhenUnspecified = true;
                    options.ReportApiVersions = true;
                    options.ApiVersionReader = new UrlSegmentApiVersionReader();
                })
                .AddVersionedApiExplorer(options =>
                {
                    // Formats version as "'v'[major][.minor][-status]".
                    options.GroupNameFormat = "'v'VV";
                    options.SubstituteApiVersionInUrl = true;
                })
                .AddProblemDetails(options =>
                {
                    SetProblemDetailsOptions(options, enviroment, knownExceptionTypes);
                })
                .AddControllers(options =>
                {
                    options.Conventions.Add(new RouteTokenTransformerConvention(new SlugifyParameterTransformer()));
                });

            services.AddAutoMapper(Assembly.GetExecutingAssembly());

            return services;
        }

        private static void SetProblemDetailsOptions(ProblemDetailsOptions options, IHostEnvironment enviroment, Type[] knownExceptionTypes)
        {
            options.IncludeExceptionDetails = (_, exception) =>
                enviroment.IsDevelopment() &&
                !knownExceptionTypes.Contains(exception.GetType());

            options.Map<ValidationException>(exception =>
                new ValidationProblemDetails(exception.Errors)
                {
                    Title = exception.Title,
                    Detail = exception.Detail,
                    Status = StatusCodes.Status400BadRequest
                });

            options.Map<NotFoundException>(exception =>
                new ValidationProblemDetails
                {
                    Title = exception.Title,
                    Detail = exception.Detail,
                    Status = StatusCodes.Status404NotFound
                });
        }
    }
}
  • AddApiVersioning
    • DefaultApiVersion defines default version of an api
    • AssumeDefaultVersionWhenUnspecified lets the router to fallback to default version specifed by DefaultApiVersion setting.
    • ReportApiVersions will add HTTP headers “api-supported-versions” and “api-deprecated-versions” to all valid service routes
    • ApiVersionReader sets versioning approach
      • UrlSegmentApiVersionReader – to pass api version via url
      • QueryStringAPiVersionReader – to pass api version via query string
      • HeaderApiVersionReader – to pass api version via headers
      • MediaTypeApiVersionReader – to pass api version by extending media types
      • Combined Approach
options.ApiVersionReader = ApiVersionReader.Combine(
		new UrlSegmentApiVersionReader(), 
		new HeaderApiVersionReader("api-version"), 
		new QueryStringApiVersionReader("api-version"),
    	new MediaTypeApiVersionReader("version"));
  • AddVersionedApiExplorer
    • GroupNameFormat is format used to create group names from API version
    • SubstituteApiVersionInUrl tells if api version parameter should be replaced or not

Now, let’s take a look at our ApiControlerBase.cs

namespace Company.Shorts.Presentation.Api.Controllers
{
    using AutoMapper;
    using MediatR;
    using Microsoft.AspNetCore.Mvc;
    using System.Net.Mime;

    [ApiController]
    [Produces(MediaTypeNames.Application.Json)]
    [Route("api/v{version:apiVersion}/[controller]")]
    public abstract class ApiControllerBase : ControllerBase
    {
        private readonly IMediator mediator;
        private readonly IMapper mapper;

        protected ApiControllerBase(
            IMediator mediator,
            IMapper mapper)
        {
            this.mediator = mediator;
            this.mapper = mapper;
        }

        protected async Task<IActionResult> ProcessAsync<TApiDto, TCommand, TResponse, TResponseDto>(
            TApiDto request,
            Action<IMappingOperationOptions<object, TCommand>>? opts = null)
            where TApiDto : IApiDto
            where TCommand : IRequest<TResponse>
        {
            TCommand? command = opts is not null
                ? this.mapper.Map<TCommand>(request, opts)
                : this.mapper.Map<TCommand>(request);

            TResponse result = await this.mediator.Send(command);

            if (result is null)
            {
                return this.NotFound();
            }

            var mapped = this.mapper.Map<TResponseDto>(result);

            return this.Ok(mapped);
        }

        protected async Task<IActionResult> ProcessAsync<TApiDto, TCommand>(
            TApiDto request,
            Action<IMappingOperationOptions<object, TCommand>>? opts = null)
            where TApiDto : IApiDto
            where TCommand : IRequest
        {
            TCommand command = opts is not null
                ? this.mapper.Map<TCommand>(request, opts)
                : this.mapper.Map<TCommand>(request);

            await this.mediator.Send(command);

            return this.NoContent();
        }
    }
}
  • [ApiController] – marks class as controller, this is what is picked up on startup and tells our application that this is a controller
  • [Produces] – tells which content type this controller will return in response
  • [Route] – in order to support path versioning, we need to tell template what it should replace

Let’s take a look at our controller

namespace Company.Shorts.Presentation.Api.Controllers.V1
{
    using AutoMapper;
    using MediatR;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Company.Shorts.Application.ExampleAggregate.Command;
    using Company.Shorts.Application.ExampleAggregate.Common.Responses;
    using Company.Shorts.Application.ExampleAggregate.Query;
    using Company.Shorts.Presentation.Api.Controllers.V1.Models.Examples;
    using Company.Shorts.Presentation.Api.Internal.Constants;
    using Swashbuckle.AspNetCore.Annotations;

    [ApiVersion(ApiVersions.V1)]
    public class ExamplesController : ApiControllerBase
    {
        public ExamplesController(IMediator mediator, IMapper mapper) : base(mediator, mapper)
        {
        }
    
        [HttpGet]
        [SwaggerOperation(
            OperationId = nameof(Get),
            Tags = new[] { ApiTags.Examples },
            Description = "Gets a list of examples.",
            Summary = "Retrieve list of examples.")]
        [SwaggerResponse(
            StatusCodes.Status200OK,
            Description = "Successfull operation.",
            Type = typeof(List<ExampleResponseDto>))]
        [SwaggerResponse(
            StatusCodes.Status400BadRequest,
            Description = "Bad request.",
            Type = typeof(ValidationProblemDetails))]
        [SwaggerResponse(
            StatusCodes.Status500InternalServerError,
            Description = "Internal server error.",
            Type = typeof(ProblemDetails))]
        public async Task<IActionResult> Get([FromQuery] GetExamplesQueryDto request)
        {
            return await ProcessAsync<GetExamplesQueryDto, GetExamplesQuery, List<ExampleResponse>, List<ExampleResponseDto>>(request);
        }

        [HttpPost]
        [SwaggerOperation(
            OperationId = nameof(Post),
            Description = "Creates new example.",
            Summary = "Creates new example.",
            Tags = new[] { ApiTags.Examples })]
        [SwaggerResponse(
            StatusCodes.Status204NoContent,
            Description = "Successfull operation.")]
        [SwaggerResponse(
            StatusCodes.Status400BadRequest,
            Description = "Bad request.",
            Type = typeof(ValidationProblemDetails))]
        [SwaggerResponse(
            StatusCodes.Status500InternalServerError,
            Description = "Internal server error.",
            Type = typeof(ProblemDetails))]
        public async Task<IActionResult> Post([FromBody] CreateExampleCommandDto request)
        {
            return await ProcessAsync<CreateExampleCommandDto, CreateExampleCommand>(request);
        }

        [HttpPut("{id}")]
        [SwaggerOperation(
            OperationId = nameof(Put),
            Description = "Updates an existing example by unique identifier.",
            Summary = "Updates existing example.",
            Tags = new[] { ApiTags.Examples })]
        [SwaggerResponse(
            StatusCodes.Status204NoContent,
            Description = "Successfull operation.")]
        [SwaggerResponse(
            StatusCodes.Status400BadRequest,
            Description = "Bad request.",
            Type = typeof(ValidationProblemDetails))]
        [SwaggerResponse(
            StatusCodes.Status500InternalServerError,
            Description = "Internal server error.",
            Type = typeof(ProblemDetails))]
        public async Task<IActionResult> Put(Guid id, [FromBody] UpdateExampleCommandDto request)
        {
            return await ProcessAsync<UpdateExampleCommandDto, UpdateExampleCommand>(request, opt => opt.AfterMap((_, command) =>
            {
                command.Id = id;
            }));
        }

        [HttpGet("{id}")]
        [SwaggerOperation(
            OperationId = nameof(GetById),
            Tags = new[] { ApiTags.Examples },
            Description = "Retrieves an example by unique identifier.",
            Summary = "Retrieve an examples.")]
        [SwaggerResponse(
            StatusCodes.Status200OK,
            Description = "Successfull operation.",
            Type = typeof(ExampleResponseDto))]
        [SwaggerResponse(
            StatusCodes.Status400BadRequest,
            Description = "Bad request.",
            Type = typeof(ValidationProblemDetails))]
        [SwaggerResponse(
            StatusCodes.Status500InternalServerError,
            Description = "Internal server error.",
            Type = typeof(ProblemDetails))]
        public async Task<IActionResult> GetById(Guid id)
        {
            return await ProcessAsync<GetExampleQueryDto, GetExampleQuery, ExampleResponse, ExampleResponseDto>(new GetExampleQueryDto(id));
        }
    }
}
  • [ApiVersion] – yup, this is where we tell which version this is

The last thing we need to do is to set up our OpenAPI Specification. For that, we are going to use Swashbuckle.

Let’s start with configuration.

SwaggerInfo.cs is just a simple DTO.

namespace Company.Shorts.Presentation.Api.Internal.Configuration.Swagger
{
    /// <summary>
    /// Swagger information.
    /// </summary>
    public class SwaggerInfo
    {
        public string? Title { get; set; }

        public string? Description { get; set; }

        public string? DeprecationDescription { get; set; }

        public ContactInfo? Contact { get; set; }

        public LicenceInfo? Licence { get; set; }

        public class ContactInfo
        {
            public string Name { get; set; } = default!;

            public string Email { get; set; } = default!;

            public string Url { get; set; } = default!;
        }

        public class LicenceInfo
        {
            public string Name { get; set; } = default!;

            public string Url { get; set; } = default!;
        }
    }
}

SwaggerDefaultValues.cs – take a look at the links in the code for more information.

namespace Company.Shorts.Presentation.Api.Internal.Configuration.Swagger
{
    using Microsoft.AspNetCore.Mvc.ApiExplorer;
    using Microsoft.OpenApi.Models;
    using Swashbuckle.AspNetCore.SwaggerGen;
    using System.Text.Json;

    /// <summary>
    /// Represents the Swagger/Swashbuckle operation filter used to document the implicit API version parameter.
    /// </summary>
    /// <remarks>
    /// <see cref="IOperationFilter"/> is only required due to bugs in <see cref="SwaggerGenerator"/>.
    /// Once they are fixed and published, this class can be removed.
    /// </remarks>
    public class SwaggerDefaultValues : IOperationFilter
    {
        /// <summary>
        /// Applies the filter to the specified operation using the given context.
        /// </summary>
        /// <param name="operation">The operation to apply the filter to.</param>
        /// <param name="context">The current operation filter context.</param>
        /// <exception cref="NotImplementedException"></exception>
        public void Apply(OpenApiOperation operation, OperationFilterContext context)
        {
            ApiDescription apiDescription = context.ApiDescription;

            operation.Deprecated |= apiDescription.IsDeprecated();

            // References: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue=66991077
            foreach (ApiResponseType responseType in context.ApiDescription.SupportedResponseTypes)
            {
                var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString();

                OpenApiResponse response = operation.Responses[responseKey];

                foreach (var contentType in response.Content.Keys)
                {
                    if (responseType.ApiResponseFormats.All(x => x.MediaType != contentType))
                    {
                        response.Content.Remove(contentType);
                    }
                }
            }

            if (operation.Parameters is null)
            {
                return;
            }

            /* References:
             * https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412
             * https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413
             */

            foreach (var parameter in operation.Parameters)
            {
                ApiParameterDescription description = apiDescription
                    .ParameterDescriptions
                    .First(parameterDescription =>
                        parameterDescription.Name == parameter.Name);

                parameter.Description ??= description.ModelMetadata?.Description;

                if (parameter.Schema is null &&
                    description.DefaultValue is not null)
                {
                    var json = JsonSerializer.Serialize(description.DefaultValue, description.ModelMetadata!.ModelType);
                    parameter.Schema!.Default = OpenApiAnyFactory.CreateFromJson(json);
                }

                parameter.Required |= description.IsRequired;
            }
        }
    }
}

ConfigureSwaggerOptions.cs will create open API doc for every api version.

namespace Company.Shorts.Presentation.Api.Internal.Configuration.Swagger
{
    using Microsoft.AspNetCore.Mvc.ApiExplorer;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Options;
    using Microsoft.OpenApi.Models;
    using Swashbuckle.AspNetCore.SwaggerGen;

    public sealed class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
    {
        private readonly IApiVersionDescriptionProvider provider;
        private readonly IConfiguration configuration;

        public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider, IConfiguration configuration)
        {
            this.provider = provider;
            this.configuration = configuration;
        }

        public void Configure(SwaggerGenOptions options)
        {
            foreach (ApiVersionDescription description in provider.ApiVersionDescriptions)
            {
                options.SwaggerDoc(
                    name: description.GroupName,
                    info: CreateInfoForApiVersion(description));
            }
        }

        private OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description)
        {
            SwaggerInfo? swaggerInfo = this.configuration
                .GetSection(nameof(SwaggerInfo))
                .Get<SwaggerInfo>();

            var info = new OpenApiInfo()
            {
                Title = swaggerInfo.Title ?? "Api Documentation",
                Version = description.ApiVersion.ToString(),
                Description = swaggerInfo.Description ?? string.Empty
            };

            if (swaggerInfo.Contact is not null)
            {
                info.Contact = new OpenApiContact()
                {
                    Name = swaggerInfo.Contact.Name,
                    Email = swaggerInfo.Contact.Email,
                    Url = new Uri(swaggerInfo.Contact.Url)
                };
            }

            if (swaggerInfo.Licence is not null)
            {
                info.License = new OpenApiLicense()
                {
                    Name = swaggerInfo.Licence.Name,
                    Url = new Uri(swaggerInfo.Licence.Url)
                };
            }

            return info;
        }
    }
}

Now that we have all the setup, we need to register it.

namespace Company.Shorts.Presentation.Api.Internal.Configuration.Swagger
{
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Options;
    using Swashbuckle.AspNetCore.Filters;
    using Swashbuckle.AspNetCore.SwaggerGen;
    using System.Reflection;

    internal static class ServiceCollectionExtensions
    {
        public static IServiceCollection AddSwaggerConfiguration(this IServiceCollection services)
        {
            services
                .AddSwaggerExamplesFromAssemblies(Assembly.GetExecutingAssembly())
                .AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>()
                .AddSwaggerGen(options =>
                {
                    options.EnableAnnotations();
                    options.ExampleFilters();
                    options.OperationFilter<SwaggerDefaultValues>();

                    foreach (var xmlFile in Directory.GetFiles(AppContext.BaseDirectory, "*.xml"))
                    {
                        var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
                        options.IncludeXmlComments(xmlPath, true);
                    }
                });

            return services;
        }
    }
}

And use it

namespace Company.Shorts.Presentation.Api.Internal.Configuration.Swagger
{
    using Microsoft.AspNetCore.Builder;
    using Microsoft.AspNetCore.Mvc.ApiExplorer;
    using Microsoft.Extensions.DependencyInjection;

    /// <summary>
    /// Exstension class for <see cref="IApplicationBuilder"/>.
    /// </summary>
    public static class ApplicationBuilderExtensions
    {
        public static IApplicationBuilder UseSwaggerConfiguration(this IApplicationBuilder builder)
        {
            IApiVersionDescriptionProvider provider = builder.ApplicationServices.GetRequiredService<IApiVersionDescriptionProvider>();

            builder
                .UseSwagger()
                .UseSwaggerUI(options =>
                {
                    options.DisplayOperationId();
                    options.DisplayRequestDuration();

                    foreach (ApiVersionDescription description in provider.ApiVersionDescriptions)
                    {
                        options.SwaggerEndpoint(
                            $"/swagger/{description.GroupName}/swagger.json",
                            description.GroupName.ToUpperInvariant());
                    }
                });

            return builder;
        }
    }
}

Let’s create extension method for our Presentation Layer which we are going to call from our Startup.cs

namespace Company.Shorts.Presentation.Api
{
    using Company.Shorts.Presentation.Api.Internal.Configuration.Rest;
    using Company.Shorts.Presentation.Api.Internal.Configuration.Swagger;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;

    public static class DependecyInjection
    {
        public static IServiceCollection AddPresentationLayer(this IServiceCollection services, IHostEnvironment environment)
        {
            services
                .AddSwaggerConfiguration();

            services
                .AddRestApiConfiguration(environment);

            return services;
        }
    }
}

Now that we have everything set up, lets take a look at how can we document everything. First approach would be using annotations

namespace Company.Shorts.Presentation.Api.Controllers.V1
{
    using AutoMapper;
    using MediatR;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Company.Shorts.Application.ExampleAggregate.Command;
    using Company.Shorts.Application.ExampleAggregate.Common.Responses;
    using Company.Shorts.Application.ExampleAggregate.Query;
    using Company.Shorts.Presentation.Api.Controllers.V1.Models.Examples;
    using Company.Shorts.Presentation.Api.Internal.Constants;
    using Swashbuckle.AspNetCore.Annotations;

    [ApiVersion(ApiVersions.V1)]
    public class ExamplesController : ApiControllerBase
    {
        public ExamplesController(IMediator mediator, IMapper mapper) : base(mediator, mapper)
        {
        }
    
        [HttpGet]
        [SwaggerOperation(
            OperationId = nameof(Get),
            Tags = new[] { ApiTags.Examples },
            Description = "Gets a list of examples.",
            Summary = "Retrieve list of examples.")]
        [SwaggerResponse(
            StatusCodes.Status200OK,
            Description = "Successfull operation.",
            Type = typeof(List<ExampleResponseDto>))]
        [SwaggerResponse(
            StatusCodes.Status400BadRequest,
            Description = "Bad request.",
            Type = typeof(ValidationProblemDetails))]
        [SwaggerResponse(
            StatusCodes.Status500InternalServerError,
            Description = "Internal server error.",
            Type = typeof(ProblemDetails))]
        public async Task<IActionResult> Get([FromQuery] GetExamplesQueryDto request)
        {
            return await ProcessAsync<GetExamplesQueryDto, GetExamplesQuery, List<ExampleResponse>, List<ExampleResponseDto>>(request);
        }

        [HttpPost]
        [SwaggerOperation(
            OperationId = nameof(Post),
            Description = "Creates new example.",
            Summary = "Creates new example.",
            Tags = new[] { ApiTags.Examples })]
        [SwaggerResponse(
            StatusCodes.Status204NoContent,
            Description = "Successfull operation.")]
        [SwaggerResponse(
            StatusCodes.Status400BadRequest,
            Description = "Bad request.",
            Type = typeof(ValidationProblemDetails))]
        [SwaggerResponse(
            StatusCodes.Status500InternalServerError,
            Description = "Internal server error.",
            Type = typeof(ProblemDetails))]
        public async Task<IActionResult> Post([FromBody] CreateExampleCommandDto request)
        {
            return await ProcessAsync<CreateExampleCommandDto, CreateExampleCommand>(request);
        }

        [HttpPut("{id}")]
        [SwaggerOperation(
            OperationId = nameof(Put),
            Description = "Updates an existing example by unique identifier.",
            Summary = "Updates existing example.",
            Tags = new[] { ApiTags.Examples })]
        [SwaggerResponse(
            StatusCodes.Status204NoContent,
            Description = "Successfull operation.")]
        [SwaggerResponse(
            StatusCodes.Status400BadRequest,
            Description = "Bad request.",
            Type = typeof(ValidationProblemDetails))]
        [SwaggerResponse(
            StatusCodes.Status500InternalServerError,
            Description = "Internal server error.",
            Type = typeof(ProblemDetails))]
        public async Task<IActionResult> Put(Guid id, [FromBody] UpdateExampleCommandDto request)
        {
            return await ProcessAsync<UpdateExampleCommandDto, UpdateExampleCommand>(request, opt => opt.AfterMap((_, command) =>
            {
                command.Id = id;
            }));
        }

        [HttpGet("{id}")]
        [SwaggerOperation(
            OperationId = nameof(GetById),
            Tags = new[] { ApiTags.Examples },
            Description = "Retrieves an example by unique identifier.",
            Summary = "Retrieve an examples.")]
        [SwaggerResponse(
            StatusCodes.Status200OK,
            Description = "Successfull operation.",
            Type = typeof(ExampleResponseDto))]
        [SwaggerResponse(
            StatusCodes.Status400BadRequest,
            Description = "Bad request.",
            Type = typeof(ValidationProblemDetails))]
        [SwaggerResponse(
            StatusCodes.Status500InternalServerError,
            Description = "Internal server error.",
            Type = typeof(ProblemDetails))]
        public async Task<IActionResult> GetById(Guid id)
        {
            return await ProcessAsync<GetExampleQueryDto, GetExampleQuery, ExampleResponse, ExampleResponseDto>(new GetExampleQueryDto(id));
        }
    }
}

namespace Company.Shorts.Presentation.Api.Controllers.V1.Models.Examples
{
    using Swashbuckle.AspNetCore.Annotations;

    [SwaggerSchema(Description = "Request for creating an example.")]
    public class CreateExampleCommandDto : IApiDto
    {
        public CreateExampleCommandDto(string name)
        {
            this.Name = name;
        }

        [SwaggerSchema(Description = "Name of the example", Nullable = false, ReadOnly = true)]
        public string Name { get; set; } = default!;
    }
}

The second one would be using xml comments

namespace Company.Shorts.Presentation.Api.Controllers.V2
{
    using AutoMapper;
    using MediatR;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Company.Shorts.Application.ExampleAggregate.Command;
    using Company.Shorts.Application.ExampleAggregate.Common.Responses;
    using Company.Shorts.Application.ExampleAggregate.Query;
    using Company.Shorts.Presentation.Api.Controllers.V2.Models.Examples;
    using Company.Shorts.Presentation.Api.Internal.Constants;
    using Swashbuckle.AspNetCore.Annotations;

    [ApiVersion(ApiVersions.V2)]
    public class ExamplesController : ApiControllerBase
    {
        public ExamplesController(IMediator mediator, IMapper mapper) : base(mediator, mapper)
        {
        }

        /// <summary>
        /// Gets examples.
        /// </summary>
        /// <remarks>This is just a simple remark.</remarks>
        /// <param name="request">Model for quering examples.</param>
        /// <response code="200">Successful.</response>
        /// <response code="400">Bad request.</response>
        /// <response code="500">Internal server error.</response>
        [HttpGet]
        [SwaggerOperation(OperationId = nameof(Get), Tags = new[] { ApiTags.Examples })]
        [ProducesResponseType(typeof(List<ExampleResponseDto>), StatusCodes.Status200OK)]
        [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
        [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
        public async Task<IActionResult> Get([FromQuery] GetExamplesQueryDto request)
        {
            return await ProcessAsync<GetExamplesQueryDto, GetExamplesQuery, List<ExampleResponse>, List<ExampleResponseDto>>(request);
        }

        /// <summary>
        /// Creates example.
        /// </summary>
        /// <param name="request">Model for creating an example.</param>
        /// <response code="200">Successful.</response>
        /// <response code="400">Bad request.</response>
        /// <response code="500">Internal server error.</response>
        [HttpPost]
        [SwaggerOperation(OperationId = nameof(Post), Tags = new[] { ApiTags.Examples })]
        [ProducesResponseType(StatusCodes.Status204NoContent)]
        [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
        [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
        public async Task<IActionResult> Post([FromBody] CreateExampleCommandDto request)
        {
            return await ProcessAsync<CreateExampleCommandDto, CreateExampleCommand>(request);
        }

        /// <summary>
        /// Updates example.
        /// </summary>        
        /// <response code="200">Successful.</response>
        /// <response code="400">Bad request.</response>
        /// <response code="500">Internal server error.</response>
        [HttpPut("{id}")]
        [SwaggerOperation(OperationId = nameof(Put), Tags = new[] { ApiTags.Examples })]
        [ProducesResponseType(StatusCodes.Status204NoContent)]
        [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
        [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
        public async Task<IActionResult> Put(Guid id, [FromBody] UpdateExampleCommandDto request)
        {
            return await ProcessAsync<UpdateExampleCommandDto, UpdateExampleCommand>(request, opt => opt.AfterMap((_, command) =>
            {
                command.Id = id;
            }));
        }

        /// <summary>
        /// Gets example by id.
        /// </summary>
        /// <response code="200">Successful.</response>
        /// <response code="400">Bad request.</response>
        /// <response code="500">Internal server error.</response>
        [HttpGet("{id}")]
        [SwaggerOperation(OperationId = nameof(GetById), Tags = new[] { ApiTags.Examples })]
        [ProducesResponseType(typeof(ExampleResponseDto), StatusCodes.Status200OK)]
        [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
        [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
        public async Task<IActionResult> GetById(Guid id)
        {
            return await ProcessAsync<GetExampleQueryDto, GetExampleQuery, ExampleResponse, ExampleResponseDto>(new GetExampleQueryDto(id));
        }
    }
}

namespace Company.Shorts.Presentation.Api.Controllers.V2.Models.Examples
{

    /// <summary>
    /// Request for creating an example.
    /// </summary>
    public class CreateExampleCommandDto : IApiDto
    {
        public CreateExampleCommandDto(string name)
        {
            Name = name;
        }

        /// <summary>
        /// Name of an example.
        /// </summary>
        public string Name { get; set; }
    }
}

In order to set up xml comments, you have to add something to you .csproj file

<PropertyGroup>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>1591</NoWarn>
</PropertyGroup>

And that’s it. You can find source code:

  • Github
  • Branch: steps/rest_api_versioning