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

StarBlog Development Notes (22): Developing Blog Post Related APIs

Introduction This article covers the development of blog post related APIs. As the first article introducing API development, it will be written in more detail to get the ball rolling; later APIs will only be covered briefly, with the focus on the surrounding infrastructure of WebApi development. APIs involved: post CRUD, pinning posts, recommending posts, etc. Before we start, a brief introduction to the basic concepts of the AspNetCore framework. Both the MVC pattern (frontend and backend not separated) and the WebApi pattern (frontend and backend separated) have Controllers. The difference is that the former's Controller inherits from Contr

曦远 (DealiAxy)约 53 分钟
目录23 个章节

Introduction

This article covers the development of blog post related APIs. As the first article introducing API development, it will be written in more detail to get the ball rolling; later APIs will only be covered briefly, with the focus on the surrounding infrastructure of WebApi development.

APIs involved: post CRUD, pinning posts, recommending posts, etc.

Before we start, a brief introduction to the basic concepts of the AspNetCore framework. Both the MVC pattern (frontend and backend not separated) and the WebApi pattern (frontend and backend separated) have Controllers.

The difference is that the former's Controller inherits from the Controller class, while the latter inherits from the ControllerBase class.

Whether for the blog frontend or the APIs, most of the logic is shared, so I encapsulated this logic in service to reduce redundant code.

Post CRUD

In previous articles, the post list and post detail features have already been implemented, which means the R (Retrieve) "read" part of CRUD is done.

The related code is in the StarBlog.Web/Services/PostService.cs file.

PS: According to the RESTFul specification, different CRUD operations correspond to different HTTP methods

In AspNetCore, you can mark the HTTP method and URL used by an endpoint by adding attributes such as [HttpPost] and [HttpDelete("{id}")] on the Action.

Now we need to implement the create, update, and delete features.

Create and Update (Create/Update)

Because these two features are pretty similar, they're implemented together. Many ORMs also combine Insert and Update, i.e. InsertOrUpdate

DTO

In computer programming, a data transfer object (DTO) is an object that carries data between two processes. Inter-process communication is typically used for expensive operations of remote interfaces (such as web services). The bulk of the cost is the round-trip communication time between the client and the server. To reduce the number of such calls, DTOs are used to aggregate data that would otherwise need to be transferred over multiple communications.

The difference between a DTO and a business object or data access object is that a DTO does not have any behavior except for storage, retrieval, serialization, and deserialization of its data (mutators and accessors, parsers, serializers). That is, a DTO is a simple object that contains no business logic, but may include serialization and deserialization for transferring data.

by Wikipedia

Adding a post only requires a few properties of the Post model, so it's not suitable to use the entire Post model as a parameter. Therefore, we first need to define a DTO as the parameter for adding a post.

File path StarBlog.Web/ViewModels/Blog/PostCreationDto.cs

public class PostCreationDto {
    /// <summary>
    /// 标题
    /// </summary>
    public string? Title { get; set; }

    /// <summary>
    /// 梗概
    /// </summary>
    public string? Summary { get; set; }

    /// <summary>
    /// 内容(markdown格式)
    /// </summary>
    public string? Content { get; set; }
    
    /// <summary>
    /// 分类ID
    /// </summary>
    public int CategoryId { get; set; }
}

AutoMapper

With a DTO as the parameter, when saving a post we would need to manually assign the properties from the DTO object to the Post object one by one, like this:

var post = new Post {
    Id = Guid.NewGuid(),
	Title = dto.Title,
    Summary = dto.Summary,
    Content = dto.Content,
    CategoryId = dto.CategoryId
};

One or two is fine, but with more APIs, a large amount of repetitive code becomes quite annoying, and it's also easy to make mistakes.

Fortunately, we can use the AutoMapper component to implement automatic object mapping.

Install the AutoMapper.Extensions.Microsoft.DependencyInjection package via nuget

Register the service:

builder.Services.AddAutoMapper(typeof(Program));

Then create the corresponding Profile (configuration). If there's no special configuration, you actually don't need to add this config file and can just use the default mapping behavior.

As an example, this article will briefly introduce it. Create the StarBlog.Web/Properties/AutoMapper/PostProfile.cs file

public class PostProfile : Profile {
    public PostProfile() {
        CreateMap<PostUpdateDto, Post>();
        CreateMap<PostCreationDto, Post>();
    }
}

In the constructor, call CreateMap to configure the mapping from left to right.

The code above configures the mapping from the PostUpdateDto / PostCreationDto objects to the Post object.

If some fields should not be mapped, you can write it like this:

public class PostProfile : Profile {
    private readonly List<string> _unmapped = new List<string> {
        "Categories",
    };
    public PostProfile() {
        CreateMap<PostUpdateDto, Post>();
        CreateMap<PostCreationDto, Post>();
        ShouldMapProperty = property => !_unmapped.Contains(property.Name);
    }
}

The rest of the code stays the same; just modify the _unmapped field.

Then inject the IMapper object in the Controller

private readonly IMapper _mapper;

Usage is very simple

var post = _mapper.Map<Post>(dto);

Pass in a dto of type PostCreationDto and you get a Post object.

Controller

First, the Controller code

[Authorize]
[ApiController]
[Route("Api/[controller]")]
[ApiExplorerSettings(GroupName = "blog")]
public class BlogPostController : ControllerBase {
    private readonly IMapper _mapper;
    private readonly PostService _postService;
    private readonly BlogService _blogService;
    
    public BlogPostController(PostService postService, BlogService blogService, IMapper mapper) {
        _postService = postService;
        _blogService = blogService;
        _mapper = mapper;
    }
}

Here's a one-by-one introduction to the four attributes on the Controller

  • Authorize means all APIs under this controller require login to access
  • ApiController indicates this is a WebApi Controller
  • Route specifies this Controller's route template, meaning all the APIs below start with Api/BlogPostController
  • ApiExplorerSettings is for API grouping, which looks clearer in the swagger docs

Next, add and update are two APIs, discussed separately.

Add

Easy, here's the code

[HttpPost]
public async Task<ApiResponse<Post>> Add(PostCreationDto dto, [FromServices] CategoryService categoryService) {
    // 使用 AutoMapper,前面介绍过的
    var post = _mapper.Map<Post>(dto);
    // 获取文章分类,如果不存在就返回报错信息
    var category = categoryService.GetById(dto.CategoryId);
    if (category == null) return ApiResponse.BadRequest($"分类 {dto.CategoryId} 不存在!");

    // 生成文章的ID、创建、更新时间
    post.Id = GuidUtils.GuidTo16String();
    post.CreationTime = DateTime.Now;
    post.LastUpdateTime = DateTime.Now;
    // 设置文章状态为已发布
    post.IsPublish = true;

    // 获取分类的层级结构
    post.Categories = categoryService.GetCategoryBreadcrumb(category);

    return new ApiResponse<Post>(await _postService.InsertOrUpdateAsync(post));
}

This is the Add method

Currently CategoryService is only needed in this add API, so there's no need to inject it at the Controller level; inject it in the Add method using the [FromServices] attribute.

Later there's getting the category hierarchy. Because StarBlog is designed to support multi-level categories, to reduce computation when displaying the post category hierarchy on the frontend, I store the post's category hierarchy (in the form of category IDs separated by commas, e.g.: 1,3,5,7,9) directly in the database — trading space for time.

Finally, call the InsertOrUpdateAsync method in PostService to parse and process the post content and save the post to the database.

PS: This project's API return values have already been uniformly wrapped. You can see ApiResponse used extensively as the return type; this will be covered in a later article.

Update

Oh, there's also the update post (Update) API. Updates use the PUT method

[HttpPut("{id}")]
public async Task<ApiResponse<Post>> Update(string id, PostUpdateDto dto) {
    // 先获取文章对象
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");

	// 在已有对象的基础上进行映射
    post = _mapper.Map(dto, post);
    // 更新修改时间
    post.LastUpdateTime = DateTime.Now;
    
    return new ApiResponse<Post>(await _postService.InsertOrUpdateAsync(post));
}

Still very simple; the comments inside are quite clear

AutoMapper can perform mapping on top of an existing object

  • mapper.Map(source) gets a brand new object
  • mapper.Map(source, dest) modifies based on the dest object

Done.

Service

As a multi-layered architecture project, the core logic still goes in the Service

And here create and update are combined into one — elegant~

public async Task<Post> InsertOrUpdateAsync(Post post) {
    var postId = post.Id;
    // 是新文章的话,先保存到数据库
    if (await _postRepo.Where(a => a.Id == postId).CountAsync() == 0) {
        post = await _postRepo.InsertAsync(post);
    }

    // 检查文章中的外部图片,下载并进行替换
    // todo 将外部图片下载放到异步任务中执行,以免保存文章的时候太慢
    post.Content = await MdExternalUrlDownloadAsync(post);
    // 修改文章时,将markdown中的图片地址替换成相对路径再保存
    post.Content = MdImageLinkConvert(post, false);

    // 处理完内容再更新一次
    await _postRepo.UpdateAsync(post);
    return post;
}

Additionally, this part of the code was already covered in previous articles related to markdown rendering and automatically downloading external images, so this article won't repeat it. For details, see article 17 of this series.

Delete

Not much to say, here's the code directly

StarBlog.Web/Services/PostService.cs

public int Delete(string id) {
    return _postRepo.Delete(a => a.Id == id);
}

StarBlog.Web/Apis/Blog/BlogPostController.cs

[HttpDelete("{id}")]
public ApiResponse Delete(string id) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var rows = _postService.Delete(id);
    return ApiResponse.Ok($"删除了 {rows} 篇博客");
}

Retrieve

Retrieve is split into two kinds: list and single.

Single

Let's start with the single one, which is relatively easy.

StarBlog.Web/Services/PostService.cs

public Post? GetById(string id) {
    // 获取文章的时候对markdown中的图片地址解析,加上完整地址返回给前端
    var post = _postRepo.Where(a => a.Id == id).Include(a => a.Category).First();
    if (post != null) post.Content = MdImageLinkConvert(post, true);

    return post;
}

StarBlog.Web/Apis/Blog/BlogPostController.cs

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

Here the endpoint is decorated with [AllowAnonymous], meaning it can be accessed without logging in.

List

The simplest approach is to return the full list of posts directly.

[HttpGet]
public List<Post> GetAll() {
    return _postService.GetAll();
}

The complete functionality is more complex, requiring filtering, sorting, pagination, and so on. These features were already covered in part 6. See: Building a Blog Project with .NetCore: StarBlog - (6) Page Development: Blog Post List

Plain CRUD is not enough to meet the functional requirements

So on top of RESTful APIs, we combine some RPC-style endpoints to implement the features we need.

Set Featured Post

There is a dedicated model for managing featured posts, named FeaturedPost

To set a featured post, just insert a record into it. Conversely, canceling means deleting the corresponding record.

Here's the code

StarBlog.Web/Services/PostService.cs

public FeaturedPost AddFeaturedPost(Post post) {
    var item = _fPostRepo.Where(a => a.PostId == post.Id).First();
    if (item != null) return item;
    item = new FeaturedPost {PostId = post.Id};
    _fPostRepo.Insert(item);
    return item;
}

StarBlog.Web/Apis/Blog/BlogPostController.cs

[HttpPost("{id}/[action]")]
public ApiResponse<FeaturedPost> SetFeatured(string id) {
    var post = _postService.GetById(id);
    return post == null
        ? ApiResponse.NotFound()
        : new ApiResponse<FeaturedPost>(_blogService.AddFeaturedPost(post));
}

After configuration, the URL becomes: Api/BlogPost/{id}/SetFeatured

Cancel Featured Post

The reverse of the featured operation above

The service looks like this

public int DeleteFeaturedPost(Post post) {
    var item = _fPostRepo.Where(a => a.PostId == post.Id).First();
    return item == null ? 0 : _fPostRepo.Delete(item);
}

The controller goes like this

[HttpPost("{id}/[action]")]
public ApiResponse CancelFeatured(string id) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var rows = _blogService.DeleteFeaturedPost(post);
    return ApiResponse.Ok($"delete {rows} rows.");
}

Set as Top Post

StarBlog is designed to allow only one top post

Setting a new top post will knock off the existing one

Service code

/// <returns>返回 <see cref="TopPost"/> 对象和删除原有置顶博客的行数</returns>
public (TopPost, int) SetTopPost(Post post) {
    var rows = _topPostRepo.Select.ToDelete().ExecuteAffrows();
    var item = new TopPost {PostId = post.Id};
    _topPostRepo.Insert(item);
    return (item, rows);
}

First delete the existing top post, then insert the new one. The return value uses tuple syntax.

Controller code

[HttpPost("{id}/[action]")]
public ApiResponse<TopPost> SetTop(string id) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var (data, rows) = _blogService.SetTopPost(post);
    return new ApiResponse<TopPost> {Data = data, Message = $"ok. deleted {rows} old topPosts."};
}

That's it, simple as that.

Upload Images

Scenario: when editing a post in the admin backend, some images will be inserted.

Because this endpoint needs to upload files, it receives parameters via FormData. The frontend needs to pay attention when making the request.

Here is the controller code:

[HttpPost("{id}/[action]")]
public ApiResponse UploadImage(string id, IFormFile file) {
    var post = _postService.GetById(id);
    if (post == null) return ApiResponse.NotFound($"博客 {id} 不存在");
    var imgUrl = _postService.UploadImage(post, file);
    return ApiResponse.Ok(new {
        imgUrl,
        imgName = Path.GetFileNameWithoutExtension(imgUrl)
    });
}

The PostService.UploadImage() method that follows won't be covered in this article (due to length constraints). I'll leave a placeholder and introduce it later together with the image management APIs~

Just now we were mostly doing CRUD on posts. Don't forget there's also a BlogController~😏

Its features include getting featured posts, getting the top post, blog overview, packaged upload, and so on.

I'll briefly introduce them here as well.

Service code for getting featured and top posts:

public List<Post> GetFeaturedPosts() {
    return _fPostRepo.Select.Include(a => a.Post.Category)
        .ToList(a => a.Post);
}
public Post? GetTopOnePost() {
    return _topPostRepo.Select.Include(a => a.Post.Category).First()?.Post;
}

The controller is too simple, so I won't write it out.

Overview Information

This isn't wrapped in a service—I don't think it will be used elsewhere, so no over-encapsulation.

Read directly from the ORM: counts of posts, categories, photos, featured items, etc.

PS: If we were to build a large-screen dashboard, this would still not be enough. We'll add more later (flag planted)

public BlogOverview Overview() {
    return new BlogOverview {
        PostsCount = _postRepo.Select.Count(),
        CategoriesCount = _categoryRepo.Select.Count(),
        PhotosCount = _photoRepo.Select.Count(),
        FeaturedPostsCount = _fPostRepo.Select.Count(),
        FeaturedCategoriesCount = _fCategoryRepo.Select.Count(),
        FeaturedPhotosCount = _fPhotoRepo.Select.Count()
    };
}

Packaged Upload

This feature works as follows: zip up locally written markdown files together with images and other resources and upload them. StarBlog parses the markdown, processes the image attachments, and stores them in the database, making it convenient to write posts locally and publish them to the blog.

The specific implementation was already covered in a previous article, so I won't repeat it here. For details, see part 18 of this series. Building a Blog Project with .NetCore: StarBlog - (18) Implementing Local Typora Post Packaged Upload

Summary

There's a lot that can be written about developing AspNetCore WebApi. I've been learning continuously during development, and many fun new features and slick tricks were added to the StarBlog project later. But to keep this series coherent to read, even if a feature was already implemented when the article was written, it might not be included. I'll write a separate article later to cover these (definitely not padding), to improve the reading experience.

Also, as a beginner-oriented tutorial, I'll try to write in fairly thorough detail (lots of rambling), which makes the articles long, but still can't cover every detail of AspNetCore. Readers who are learning as they go are advised to read along with the official AspNetCore docs or textbooks~

Series Articles

_EOF_

StarBlog Development Notes (22): Developing Blog Post Related APIs

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

相关文章

StarBlog博客开发笔记(23):文章列表接口分页、过滤、搜索、排序

前言 上一篇留的坑,火速补上。 在之前的第6篇中,已经有初步介绍,本文做一些补充,已经搞定这部分的同学可以快速跳过,基于.NetCore开发博客项目 StarBlog - (6) 页面开发之博客文章列表 对标准的WebApi来说,分页、过滤、搜索、排序是很常见的功能,既可以方便用户查看数据,又可以提升程序性能。 通用请求参数 定义一个类来作为通用的请求参数 列表接口通用的参数是这几个:PageSi

2023-01-02 · StarBlog博客开发笔记 · 约 1 分钟

讨论

共 0 条

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

加载评论中…