Practice of uniform wrapping of ASP.NET Core WebApi return results

Practice of uniform wrapping of ASP.NET Core WebApi return results

Regarding the unified return of WebApi results, it also made me think further. First, how to better restrict the unified format of the return, and secondly, the packaging of results must be simpler and more powerful. Through continuous thinking and improvement, I finally achieved preliminary results. I share them out. Learning has no end, thinking has no end. I hope this can encourage us together.

Last updated 4/13/2022 7:12 AM
yi念之间
17 min read
Category
ASP.NET Core
Tags
.NET C# ASP.NET Core Web API

Foreword

Recently, I’ve been rebuilding a framework based on ASP.NET Core WebAPI, and it has indeed brought many gains. After all, when you set out to build a framework, you can’t help but think about how to make it more complete and easier to use. In the process, I gave further thought to the unified result return of WebAPI—first, how to better enforce a uniform return format, and second, how to make the result wrapping simpler and more powerful. After continuous reflection and improvement, I finally achieved preliminary results and decided to share them. Learning never ends, and neither does thinking. I hope this can encourage you as well.

Unified Result Class Encapsulation

First, to unify the return format, we need a uniform wrapper class to encapsulate all return results. Although the return format is consistent, the type of the actual value is uncertain, so we need to define a generic class here. If you choose not to use a generic class, you could use dynamic or object types, but that might bring two drawbacks:

  • First, there could be boxing/unboxing operations.
  • Second, if Swagger is introduced, the return type cannot be generated because dynamic or object types can only be determined when the specific action executes. However, Swagger’s structure is obtained when the application first runs, so it cannot perceive the specific type.

Defining the Wrapper Class

As mentioned above, we’ll go straight to encapsulating a result return wrapper class.

public class ResponseResult<T>
{
    /// <summary>
    /// Status result
    /// </summary>
    public ResultStatus Status { get; set; } = ResultStatus.Success;

    private string? _msg;

    /// <summary>
    /// Message description
    /// </summary>
    public string? Message
    {
        get
        {
            // If no custom result description is provided, get the description of the current status
            return !string.IsNullOrEmpty(_msg) ? _msg : EnumHelper.GetDescription(Status);
        }
        set
        {
            _msg = value;
        }
    }

    /// <summary>
    /// Returned result
    /// </summary>
    public T Data { get; set; }
}

Here, ResultStatus is an enum type that defines specific return status codes to determine whether the result is normal, abnormal, or something else. I’ve simply defined a basic example; you can extend it as needed.

public enum ResultStatus
{
    [Description("Request succeeded")]
    Success = 1,
    [Description("Request failed")]
    Fail = 0,
    [Description("Request error")]
    Error = -1
}

Defining an enum type and using its DescriptionAttribute to describe the meaning is a good choice. First, it centrally manages the meaning of each status; second, it makes it easier to get the description for each status. This way, if no custom result description is provided, the description of the current status can serve as the default. When writing a specific action, it looks like this:

[HttpGet("GetWeatherForecast")]
public ResponseResult<IEnumerable<WeatherForecast>> GetAll()
{
    var datas = Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    });
    return new ResponseResult<IEnumerable<WeatherForecast>> {  Data = datas };
}

In this way, every time you write an action, you can return a ResponseResult<T> result. This demonstrates the advantage of using an enum to define status codes: in many scenarios, you can omit status codes and even messages. After all, the simpler the code, the better—especially for high-frequency operations.

Upgrading the Operation

Although we defined ResponseResult<T> to wrap return results, we still have to create a new instance each time, which is inconvenient. Moreover, we have to assign property values every time, which is also troublesome. At this point, I thought it would be nice to have some helper methods to simplify the operation. Not too many methods, just enough to cover the scenarios—essentially, “good enough.” Most importantly, it should support extensibility. Therefore, I further upgraded the result wrapper class to simplify operations.

public class ResponseResult<T>
{
    /// <summary>
    /// Status result
    /// </summary>
    public ResultStatus Status { get; set; } = ResultStatus.Success;

    private string? _msg;

    /// <summary>
    /// Message description
    /// </summary>
    public string? Message
    {
        get
        {
            return !string.IsNullOrEmpty(_msg) ? _msg : EnumHelper.GetDescription(Status);
        }
        set
        {
            _msg = value;
        }
    }

    /// <summary>
    /// Returned result
    /// </summary>
    public T Data { get; set; }

    /// <summary>
    /// Successful status return result
    /// </summary>
    /// <param name="result">Returned data</param>
    /// <returns></returns>
    public static ResponseResult<T> SuccessResult(T data)
    {
        return new ResponseResult<T> { Status = ResultStatus.Success, Data = data };
    }

    /// <summary>
    /// Failed status return result
    /// </summary>
    /// <param name="code">Status code</param>
    /// <param name="msg">Failure message</param>
    /// <returns></returns>
    public static ResponseResult<T> FailResult(string? msg = null)
    {
        return new ResponseResult<T> { Status = ResultStatus.Fail, Message = msg };
    }

    /// <summary>
    /// Error status return result
    /// </summary>
    /// <param name="code">Status code</param>
    /// <param name="msg">Error message</param>
    /// <returns></returns>
    public static ResponseResult<T> ErrorResult(string? msg = null)
    {
        return new ResponseResult<T> { Status = ResultStatus.Error, Message = msg };
    }

    /// <summary>
    /// Custom status return result
    /// </summary>
    /// <param name="status"></param>
    /// <param name="result"></param>
    /// <returns></returns>
    public static ResponseResult<T> Result(ResultStatus status, T data, string? msg = null)
    {
        return new ResponseResult<T> { Status = status, Data = data, Message = msg };
    }
}

Here, I’ve encapsulated several common methods: success, failure, error, and the most complete status. These can handle most scenarios. If insufficient, you can encapsulate more methods. This simplifies usage in actions, saving manual property assignment.

[HttpGet("GetWeatherForecast")]
public ResponseResult<IEnumerable<WeatherForecast>> GetAll()
{
    var datas = Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    });
    return ResponseResult<IEnumerable<WeatherForecast>>.SuccessResult(datas);
}

Further Improvement

The encapsulation of ResponseResult<T> has indeed saved some effort, but it’s still not perfect: every time you return a result, although several static methods are defined, you still have to manually invoke the ResponseResult<T> class. I’d like to not see it when returning results. This is easy: we can learn from Microsoft’s encapsulation of MVC Controller—define a base Controller class that encapsulates common result return methods. Then, subclasses can directly call these methods without worrying about the return type. Let’s encapsulate the base Controller.

[ApiController]
[Route("api/[controller]")]
public class ApiControllerBase : ControllerBase
{
    /// <summary>
    /// Successful status return result
    /// </summary>
    /// <param name="result">Returned data</param>
    /// <returns></returns>
    protected ResponseResult<T> SuccessResult<T>(T result)
    {
        return ResponseResult<T>.SuccessResult(result);
    }

    /// <summary>
    /// Failed status return result
    /// </summary>
    /// <param name="code">Status code</param>
    /// <param name="msg">Failure message</param>
    /// <returns></returns>
    protected ResponseResult<T> FailResult<T>(string? msg = null)
    {
        return ResponseResult<T>.FailResult(msg);
    }

    /// <summary>
    /// Error status return result
    /// </summary>
    /// <param name="code">Status code</param>
    /// <param name="msg">Error message</param>
    /// <returns></returns>
    protected ResponseResult<T> ErrorResult<T>(string? msg = null)
    {
        return ResponseResult<T>.ErrorResult(msg);
    }

    /// <summary>
    /// Custom status return result
    /// </summary>
    /// <param name="status"></param>
    /// <param name="result"></param>
    /// <returns></returns>
    protected ResponseResult<T> Result<T>(ResultStatus status, T result, string? msg = null)
    {
        return ResponseResult<T>.Result(status, result, msg);
    }
}

With this great helper, everything moves forward. Now, custom Controllers can inherit from ApiControllerBase and use the simplified operations. The code looks like this:

public class WeatherForecastController : ApiControllerBase
{
    private static readonly string[] Summaries = new[]
    {
       "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    [HttpGet("GetWeatherForecast")]
    public ResponseResult<IEnumerable<WeatherForecast>> GetAll()
    {
        var datas = Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        });
        return SuccessResult(datas);
    }
}

At this point, it’s quite nice, but we still haven’t escaped one limitation: we still have to use specific methods to get a ResponseResult<T> result—whether through static methods or the base Controller, both aim to simplify. Now, I want to break this restriction: can I directly return the result and have it automatically converted to ResponseResult<T>? Of course, yes. Inspired by ASP.NET Core’s encapsulation of ActionResult<T>, we can use implicit operator to automatically perform implicit conversion. For example, in ActionResult<T>:

public static implicit operator ActionResult<TValue>(TValue value)
{
    return new ActionResult<TValue>(value);
}

Following this idea, we can further improve ResponseResult<T> by adding an implicit conversion. Just one method in ResponseResult<T>:

/// <summary>
/// Implicitly convert T to ResponseResult<T>
/// </summary>
/// <param name="value"></param>
public static implicit operator ResponseResult<T>(T value)
{
    return new ResponseResult<T> { Data = value };
}

This greatly simplifies returning successful results. Now, when writing actions, we can further simplify:

[HttpGet("GetWeatherForecast")]
public ResponseResult<IEnumerable<WeatherForecast>> GetAll()
{
    var datas = Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateTime.Now.AddDays(index),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    });
    return datas.ToList();
}

Because we defined the implicit conversion from T to ResponseResult<T>, we can directly return the result without manually wrapping it.

Handling the Leftovers

We’ve done a lot of encapsulation to simplify returning ResponseResult<T>, but one problem remains: developers might forget to declare ResponseResult<T> as the return type of an action. In that case, all our previous encapsulation would not apply because the return type must be ResponseResult<T>. To handle these leftovers, we need a uniform interception mechanism. The first thing that comes to mind is defining a Filter. Therefore, I encapsulated a result wrapper filter as follows:

public class ResultWrapperFilter : ActionFilterAttribute
{
    public override void OnResultExecuting(ResultExecutingContext context)
    {
        var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;
        var actionWrapper = controllerActionDescriptor?.MethodInfo.GetCustomAttributes(typeof(NoWrapperAttribute), false).FirstOrDefault();
        var controllerWrapper = controllerActionDescriptor?.ControllerTypeInfo.GetCustomAttributes(typeof(NoWrapperAttribute), false).FirstOrDefault();
        // If NoWrapperAttribute is present, do not wrap the result, return original value
        if (actionWrapper != null || controllerWrapper != null)
        {
            return;
        }

        // Implement according to actual needs
        var rspResult = new ResponseResult<object>();
        if (context.Result is ObjectResult)
        {
            var objectResult = context.Result as ObjectResult;
            if (objectResult?.Value == null)
            {
                rspResult.Status = ResultStatus.Fail;
                rspResult.Message = "Resource not found";
                context.Result = new ObjectResult(rspResult);
            }
            else
            {
                // If the return result is already of type ResponseResult<T>, no further wrapping needed
                if (objectResult.DeclaredType.IsGenericType && objectResult.DeclaredType?.GetGenericTypeDefinition() == typeof(ResponseResult<>))
                {
                    return;
                }
                rspResult.Data = objectResult.Value;
                context.Result = new ObjectResult(rspResult);
            }
            return;
        }
    }
}

In WebAPI, most actions directly return ViewModel or Dto instead of ActionResult types. However, the MVC framework will automatically wrap these custom types into ObjectResult. Our ResultWrapperFilter operates based on this mechanism. Two points to consider:

  • First, we must allow that not all results need to be wrapped with ResponseResult<T>. To meet this requirement, we define the NoWrapperAttribute. If a Controller or Action is decorated with NoWrapperAttribute, the result will not be processed.
  • Second, if the action’s return type is already ResponseResult<T>, we should not wrap it again.

The definition of NoWrapperAttribute is simple:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class NoWrapperAttribute:Attribute
{
}

There is another special case: when an exception occurs, the above mechanisms will not work. Therefore, we also need a global exception handling mechanism. We can use a unified exception handling filter as follows:

public class GlobalExceptionFilter : IExceptionFilter
{
    private readonly ILogger<GlobalExceptionFilter> _logger;
    public GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
    {
        _logger = logger;
    }

    public void OnException(ExceptionContext context)
    {
        // Wrap exception result
        var rspResult = ResponseResult<object>.ErrorResult(context.Exception.Message);
        // Log
        _logger.LogError(context.Exception, context.Exception.Message);
        context.ExceptionHandled = true;
        context.Result = new InternalServerErrorObjectResult(rspResult);
    }

    public class InternalServerErrorObjectResult : ObjectResult
    {
        public InternalServerErrorObjectResult(object value) : base(value)
        {
            StatusCode = StatusCodes.Status500InternalServerError;
        }
    }
}

After writing the filters, don’t forget to register them globally:

builder.Services.AddControllers(options =>
{
    options.Filters.Add<ResultWrapperFilter>();
    options.Filters.Add<GlobalExceptionFilter>();
});

Another Approach for Leftovers

Of course, besides the filter approach, ASP.NET Core also supports middleware for handling these leftovers. The core difference between filters and middleware can be summed up in one sentence:

The processing stages are different; they work at different points in the pipeline lifecycle. Middleware can handle scenarios after any point in the pipeline, but filters only manage the Controller area.

However, for result wrapping, I think using filters is easier because we can directly access the action’s return value. If we use middleware, we have to read the Response.Body. I also encapsulated a middleware approach as follows:

public static IApplicationBuilder UseResultWrapper(this IApplicationBuilder app)
{
        var serializerOptions = app.ApplicationServices.GetRequiredService<IOptions<JsonOptions>>().Value.JsonSerializerOptions;
        serializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping;
        return app.Use(async (context, next) =>
        {
            var originalResponseBody = context.Response.Body;
            try
            {
                // Since Response.Body cannot be read directly, we need to do some trick
                using var swapStream = new MemoryStream();
                context.Response.Body = swapStream;
                await next();
                // Check if an error status code occurred; handle specially
                if (context.Response.StatusCode == StatusCodes.Status500InternalServerError)
                {
                    context.Response.Body.Seek(0, SeekOrigin.Begin);
                    await swapStream.CopyToAsync(originalResponseBody);
                    return;
                }
                var endpoint = context.Features.Get<IEndpointFeature>()?.Endpoint;
                if (endpoint != null)
                {
                    // Only process application/json results
                    if (context.Response.ContentType.ToLower().Contains("application/json"))
                    {
                        // Check if endpoint contains NoWrapperAttribute
                        NoWrapperAttribute noWrapper = endpoint.Metadata.GetMetadata<NoWrapperAttribute>();
                        if (noWrapper != null)
                        {
                            context.Response.Body.Seek(0, SeekOrigin.Begin);
                            await swapStream.CopyToAsync(originalResponseBody);
                            return;
                        }
                        // Get the action's return type
                        var controllerActionDescriptor = context.GetEndpoint()?.Metadata.GetMetadata<ControllerActionDescriptor>();
                        if (controllerActionDescriptor != null)
                        {
                            // Special handling for generics
                            var returnType = controllerActionDescriptor.MethodInfo.ReturnType;
                            if (returnType.IsGenericType && (returnType.GetGenericTypeDefinition() == typeof(Task<>) || returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)))
                            {
                                returnType = returnType.GetGenericArguments()[0];
                            }
                            // If the endpoint already returns ResponseResult<T>, skip wrapping
                            if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(ResponseResult<>))
                            {
                                context.Response.Body.Seek(0, SeekOrigin.Begin);
                                await swapStream.CopyToAsync(originalResponseBody);
                                return;
                            }
                            context.Response.Body.Seek(0, SeekOrigin.Begin);
                            // Deserialize to get the original result
                            var result = await JsonSerializer.DeserializeAsync(context.Response.Body, returnType, serializerOptions);
                            // Wrap the original result
                            var bytes = JsonSerializer.SerializeToUtf8Bytes(ResponseResult<object>.SuccessResult(result), serializerOptions);
                            new MemoryStream(bytes).CopyTo(originalResponseBody);
                            return;
                        }
                    }
                }
                context.Response.Body.Seek(0, SeekOrigin.Begin);
                await swapStream.CopyToAsync(originalResponseBody);
            }
            finally
            {
                // Restore the original body
                context.Response.Body = originalResponseBody;
            }
        });
    }
}

From the above, it’s clear which method is easier for wrapping unified results. Filters are specifically designed for handling Controller and Action results. However, middleware can handle results more comprehensively because sometimes we may intercept requests and return results directly in custom middleware. But according to the Pareto principle, such cases are rare compared to action returns. For those rare cases, we can directly use ResponseResult<T> encapsulated methods, which is also convenient.

For exception handling, using a global exception handling middleware can handle more scenarios without side effects. Here’s its definition:

public static IApplicationBuilder UseException(this IApplicationBuilder app)
{
    return app.UseExceptionHandler(configure =>
    {
        configure.Run(async context =>
        {
            var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
            var ex = exceptionHandlerPathFeature?.Error;
            if (ex != null)
            {
                var _logger = context.RequestServices.GetService<ILogger<IExceptionHandlerPathFeature>>();
                var rspResult = ResponseResult<object>.ErrorResult(ex.Message);
                _logger?.LogError(ex, message: ex.Message);
                context.Response.StatusCode = StatusCodes.Status500InternalServerError;
                context.Response.ContentType = "application/json;charset=utf-8";
                await context.Response.WriteAsync(rspResult.SerializeObject());
            }
        });
    });
}

Using a global exception handling middleware has no side effects, mainly because we don’t need to read Response.Body during exception handling. Whether you choose the exception middleware or the filter depends on your actual scenario; both are viable.

Summary

This article mainly demonstrates the unified result return format for ASP.NET Core WebAPI. Through examples, we gradually upgraded the implementation to achieve this goal. Although the overall process seems simple, it embodies repeated thinking and improvement. After each stage, I thought about whether there is a better way to refine it. Some ideas came from Microsoft’s source code, which is why I often recommend reading source code—it can provide solutions to many problems. As the saying goes, reading source code is like a siege: those outside want to get in, and those inside don’t want to come out. If you have a better implementation, feel free to discuss. In the past, I felt happy when I learned a new skill; later, I became happy about having a good idea or a good problem-solving approach. Reading ten thousand books is important, but traveling ten thousand miles is equally important. Reading is accumulation, traveling is practice; combining both can better promote progress, rather than choosing only one.

👇 Scan the QR code to follow my official account 👇

Keep Exploring

Related Reading

More Articles
Same category / Same tag 6/22/2022

ASP.NET Core WebAPI Localization (Single Resource File)

Microsoft's default approach is one class corresponding to multiple resource files, which is cumbersome to use. This article introduces the use of a single resource file, where all classes in the entire project correspond to one set of multilingual resource files.

Continue Reading