返回文章列表文章索引阅读全文
STARBLOG / 阅读手记
StarBlog博客开发笔记

StarBlog Blog Development Notes (24): Unified API Data Response Format

Introduction APIs are built for clients (web frontends, apps). RESTFul, mentioned earlier, is the API specification. With a unified API style, client developers can find the endpoints they need more quickly when accessing backend functionality, and can write more maintainable code. The API data response format is also an important part of the API specification. Otherwise, if one API returns JSON and another returns a plain string, the client will be completely dumbfounded when it receives the data and won't be able to handle it. A proper API response should include a status code, a message, and data. Like this: { st

曦远 (DealiAxy)约 43 分钟
目录14 个章节

Introduction

APIs are built for clients (web frontends, apps). RESTFul, mentioned earlier, is the API specification. With a unified API style, client developers can find the endpoints they need more quickly when accessing backend functionality, and can write more maintainable code.

The API data response format is also an important part of the API specification. Otherwise, if one API returns JSON and another returns a plain string, the client will be completely dumbfounded when it receives the data and won't be able to handle it.

A proper API response should include a status code, a message, and data.

Like this:

{
  "statusCode": 200,
  "successful": true,
  "message": null,
  "data": {}
}

By default, AspNetCore's WebAPI template has no specific response format, because these business-related things need to be defined and completed by the developer.

In previous articles, you can see that this project's API return values are all ApiResponse and its derived types—this is the unified response format customized in StarBlog. In fact, my other projects also use this set of API response values; it already counts as a Utilities-style component.

PS: While writing this article today, I went ahead and published this response type as a NuGet package, so I won't have to copy and paste when using it in other projects anymore~

Let's Analyze This

When writing a WebApi in AspNetCore, our Controller needs to inherit the ControllerBase class

An API Action can set its return type to IActionResult or ActionResult<T>, and when returning data, you can use methods encapsulated by ControllerBase such as Ok(), NotFound(), etc. These methods automatically set the HTTP status code of the response while returning data.

PS: For the difference between IActionResult and ActionResult<T>, please refer to the official documentation.

This article only mentions one key point: the ActionResult<T> return type lets you intuitively see the returned data type in the swagger documentation.

So we not only need to encapsulate a unified return value, but also implement convenience methods similar to Ok(), NotFound(), BadRequest().

Obviously, when all API return types are ApiResponse<T>, the returned status codes would all be 200, which doesn't meet the requirements.

And some APIs were already written with return types like List<T>, so we also need to wrap those APIs' return values to unify the response format.

To solve these problems, we need to understand AspNetCore's pipeline model.

AspNetCore Pipeline Model

The outermost layer is middleware. A request comes in, passes through middleware one by one, reaches the last middleware, generates a response, then walks back out through the middleware one by one, producing the final response.

Common AspNetCore project middleware includes these, as shown in the figure below:

The last Endpoint is the middleware that ultimately generates the response.

In this project, the last middleware in the Program.cs configuration adds an Endpoint that handles MVC

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

The structure of this Endpoint looks like this:

image-20221220231133444

You can see there are many Filters surrounding the user code, both before and after.

So the conclusion is, to modify a request's response, we can choose:

  • Write a middleware to handle it
  • Use a Filter

So, let's start writing code~

Defining ApiResponse

First up is this frequently appearing ApiResponse—time to reveal it at last~

Under the StarBlog.Web/ViewModels/Response namespace, I created three files:

  • ApiResponse.cs
  • ApiResponsePaged.cs: paged response
  • IApiResponse.cs: several related interfaces

In ApiResponse.cs, there are actually two classes: one ApiResponse<T> and another ApiResponse, with and without generics.

PS: C# generics are a bit complicated. I got dizzy working on this at the time, and also reviewed some contravariance and covariance, but ultimately didn't use them.

Interface Code

Here's the code, starting with the interfaces

public interface IApiResponse {
    public int StatusCode { get; set; }
    public bool Successful { get; set; }
    public string? Message { get; set; }
}

public interface IApiResponse<T> : IApiResponse {
    public T? Data { get; set; }
}

public interface IApiErrorResponse {
    public Dictionary<string,object> ErrorData { get; set; }
}

This ensures that all related objects come from the IApiResponse interface.

ApiResponse<T>

Next, look at the ApiResponse<T> code.

public class ApiResponse<T> : IApiResponse<T> {
    public ApiResponse() {
    }

    public ApiResponse(T? data) {
        Data = data;
    }

    public int StatusCode { get; set; } = 200;
    public bool Successful { get; set; } = true;
    public string? Message { get; set; }

    public T? Data { get; set; }

    /// <summary>
    /// 实现将 <see cref="ApiResponse"/> 隐式转换为 <see cref="ApiResponse{T}"/>
    /// </summary>
    /// <param name="apiResponse"><see cref="ApiResponse"/></param>
    public static implicit operator ApiResponse<T>(ApiResponse apiResponse) {
        return new ApiResponse<T> {
            StatusCode = apiResponse.StatusCode,
            Successful = apiResponse.Successful,
            Message = apiResponse.Message
        };
    }
}

Here, operator overloading is used to implement implicit conversion from ApiResponse to ApiResponse<T>.

You'll see what this is for in a moment~

ApiResponse

Continuing with the ApiResponse code, which is fairly long and encapsulates several commonly used methods, with some duplicated code.

This class implements two interfaces: IApiResponse, IApiErrorResponse

public class ApiResponse : IApiResponse, IApiErrorResponse {
    public int StatusCode { get; set; } = 200;
    public bool Successful { get; set; } = true;
    public string? Message { get; set; }
    public object? Data { get; set; }

    /// <summary>
    /// 可序列化的错误
    /// <para>用于保存模型验证失败的错误信息</para>
    /// </summary>
    public Dictionary<string,object>? ErrorData { get; set; }

    public ApiResponse() {
    }

    public ApiResponse(object data) {
        Data = data;
    }

    public static ApiResponse NoContent(string message = "NoContent") {
        return new ApiResponse {
            StatusCode = StatusCodes.Status204NoContent,
            Successful = true, Message = message
        };
    }

    public static ApiResponse Ok(string message = "Ok") {
        return new ApiResponse {
            StatusCode = StatusCodes.Status200OK,
            Successful = true, Message = message
        };
    }

    public static ApiResponse Ok(object data, string message = "Ok") {
        return new ApiResponse {
            StatusCode = StatusCodes.Status200OK,
            Successful = true, Message = message,
            Data = data
        };
    }

    public static ApiResponse Unauthorized(string message = "Unauthorized") {
        return new ApiResponse {
            StatusCode = StatusCodes.Status401Unauthorized,
            Successful = false, Message = message
        };
    }

    public static ApiResponse NotFound(string message = "NotFound") {
        return new ApiResponse {
            StatusCode = StatusCodes.Status404NotFound,
            Successful = false, Message = message
        };
    }

    public static ApiResponse BadRequest(string message = "BadRequest") {
        return new ApiResponse {
            StatusCode = StatusCodes.Status400BadRequest,
            Successful = false, Message = message
        };
    }

    public static ApiResponse BadRequest(ModelStateDictionary modelState, string message = "ModelState is not valid.") {
        return new ApiResponse {
            StatusCode = StatusCodes.Status400BadRequest,
            Successful = false, Message = message,
            ErrorData = new SerializableError(modelState)
        };
    }

    public static ApiResponse Error(string message = "Error", Exception? exception = null) {
        object? data = null;
        if (exception != null) {
            data = new {
                exception.Message,
                exception.Data
            };
        }

        return new ApiResponse {
            StatusCode = StatusCodes.Status500InternalServerError,
            Successful = false,
            Message = message,
            Data = data
        };
    }
}

ApiResponsePaged<T>

This paged version is the simplest—it just adds a Pagination property.

public class ApiResponsePaged<T> : ApiResponse<List<T>> where T : class {
    public ApiResponsePaged() {
    }

    public ApiResponsePaged(IPagedList<T> pagedList) {
        Data = pagedList.ToList();
        Pagination = pagedList.ToPaginationMetadata();
    }

    public PaginationMetadata? Pagination { get; set; }
}

Implicit Type Conversion

Take a look at this API

public ApiResponse<Post> Get(string id) {
    var post = _postService.GetById(id);
    return post == null ? ApiResponse.NotFound() : new ApiResponse<Post>(post);
}

From the code above, you can see that ApiResponse.NotFound() returns an ApiResponse object

But this API's return type is clearly ApiResponse<Post>—isn't that a type mismatch?

However, in ApiResponse<T>, we defined an operator overload that implements implicit conversion from the ApiResponse type to ApiResponse<T>, so this problem is perfectly solved, greatly reducing the amount of code.

Otherwise it would have to be written like this

return post == null ? 
    new ApiResponse<Post> {
	    StatusCode = StatusCodes.Status404NotFound,
    	Successful = false, Message = "未找到"
	} : 
	new ApiResponse<Post>(post);

Now you just need a simple ApiResponse.NotFound(), as neat as the one built into AspNetCore~

Wrapping Return Values

Besides these endpoints that use ApiResponse or ApiResponse<T> as their return type, there are many other endpoints with different return types, for example

public List<ConfigItem> GetAll() {
    return _service.GetAll();
}

And also

public async Task<string> Poem() {
    return await _crawlService.GetPoem();
}

When AspNetCore generates the response for these endpoints, it classifies these return values as ObjectResult. If left unhandled, they will be serialized directly into a format that doesn't match our return value specification.

That won't do; we must unify the return format for these endpoints as well.

For various reasons, I ultimately chose to implement this with a filter.

For detailed usage of filters, you can refer to the official documentation; this article won't go into that. Let's jump straight to the code.

Create the file StarBlog.Web/Filters/ResponseWrapperFilter.cs

public class ResponseWrapperFilter : IAsyncResultFilter {
    public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) {
        if (context.Result is ObjectResult objectResult) {
            if (objectResult.Value is IApiResponse apiResponse) {
                objectResult.StatusCode = apiResponse.StatusCode;
                context.HttpContext.Response.StatusCode = apiResponse.StatusCode;
            }
            else {
                var statusCode = objectResult.StatusCode ?? context.HttpContext.Response.StatusCode;

                var wrapperResp = new ApiResponse<object> {
                    StatusCode = statusCode,
                    Successful = statusCode is >= 200 and < 400,
                    Data = objectResult.Value,
                };

                objectResult.Value = wrapperResp;
                objectResult.DeclaredType = wrapperResp.GetType();
            }
        }

        await next();
    }
}

In the code we check: when the response type is ObjectResult, we take out that result and then check whether it is of type IApiResponse.

As we introduced earlier, all ApiResponse types implement the IApiResponse interface, so we can check whether it is of type IApiResponse to determine whether this return result has already been wrapped.

If it hasn't been wrapped, wrap it. It's that simple.

Then register this filter in Program.cs.

var mvcBuilder = builder.Services.AddControllersWithViews(
    options => { options.Filters.Add<ResponseWrapperFilter>(); }
);

Done

And that's it~

In the end, all endpoints (the serializable ones) have their return format become like this

{
  "statusCode": 200,
  "successful": true,
  "message": null,
  "data": {}
}

My OCD is satisfied~

PS: Oh, except for those endpoints that return files.

Using It in Other Projects

This ApiRepsonse has already been published as a NuGet package

If you need to use it in other projects, you can install the CodeLab.Share package directly

Just import the CodeLab.Share.ViewModels.Response namespace and you're done~

No more copying and pasting these classes every time and having to change the namespace.

PS: This package does not include the filter!

References

Series Articles

_EOF_

StarBlog Blog Development Notes (24): Unified API Data Response Format

本文作者:
程序设计实验室
版权声明:
本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

相关文章

博客上线

终于上线啦! StarBlog博客项目从春节开始,跨越三个季节终于上线了~ 其实服务器这几天已经部署好了,然后我又测试了一会,暂时觉得OK,终于可以和大家分享了~ 博客地址是:blog.deali.cn 欢迎访问哈~ PS:友链模块即将完成,欢迎各位站长大佬们来交换友链~! 系列文章 顺便回顾一下已更新的12篇文章,接下来要介绍RESTFul接口和管理后台的前端开发了,欢迎继续关注~ 基于.Net

2022-06-20 · StarBlog博客开发笔记 · 约 1 分钟

讨论

共 0 条

良言一句三冬暖,恶语伤人六月寒。

加载评论中…