Spring Boot REST API 最佳实践 - 第三章

在本章节教程中,我们将了解如何实现 FindById 和 DeleteById API 端点。

你可以在此 GitHub 仓库中找到本教程的示例代码。

实现 GET /api/bookmarks/{id} API 端点

我们希望实现一个根据指定 id 获取单个资源的 API 端点,如果找到,以 HTTP 状态码 200 返回。如果未找到,则返回 HTTP 状态码 404,可选择返回包含异常信息的响应体。

实现 GET /api/bookmarks/{id} API 端点,如下:

BookmarkRepository.java

interface BookmarkRepository extends JpaRepository<Bookmark, Long> {
    @Query("""
           SELECT
            new com.sivalabs.bookmarks.domain.BookmarkDTO(b.id, b.title, b.url, b.createdAt)
           FROM Bookmark b
           WHERE b.id = ?1
        """)
    Optional<BookmarkDTO> findBookmarkById(Long id);
}

BookmarkService.java

@Service
@Transactional(readOnly = true)
public class BookmarkService {
      private final BookmarkRepository repo;
      //...
      //...

    public Optional<BookmarkDTO> findById(Long id) {
        return repo.findBookmarkById(id);
    }
}

BookmarkController.java

@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
    private final BookmarkService bookmarkService;
    //...
    //...

    @GetMapping("/{id}")
    ResponseEntity<BookmarkDTO> findById(@PathVariable(name = "id") Long id) {
        return bookmarkService.findById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }
}

现在,你可以启动应用,并使用 CURL 调用 API 端点,如下:

$ curl --location 'http://localhost:8080/api/bookmarks/1'
  
  // response
  {"id":1,"title":"SivaLabs blog","url":"https://sivalabs.in","createdAt":"2023-08-23T04:24:17.975268Z"}

实现 DELETE /api/bookmarks/{id} API 端点

我们将遵循类似的模式,实现用于删除书签的 DELETE /api/bookmarks/{id} API 端点。

BookmarkService.java

@Service
@Transactional(readOnly = true)
public class BookmarkService {
      private final BookmarkRepository repo;
      //...
      //...

    @Transactional
    public void delete(Long postId) {
        Bookmark entity = repo.findById(postId)
                .orElseThrow(()-> BookmarkNotFoundException.of(postId));
        repo.delete(entity);
    }
}

BookmarkController.java

@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
    private final BookmarkService bookmarkService;
    //...
    //...

    @DeleteMapping("/{id}")
    void delete(@PathVariable(name = "id") Long id) {
        bookmarkService.delete(id);
    }
}

使用 CURL 调用 API 端点,如下:

$ curl --location --request DELETE 'http://localhost:8080/api/bookmarks/17'

使用 RestAssured 和 Testcontainers 测试 API 端点

为 API 端点编写测试,如下:

package com.sivalabs.bookmarks.api.controllers;

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class BookmarkControllerTests {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = 
            new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.4-alpine"));

    @LocalServerPort
    private Integer port;

    @Autowired
    private BookmarkService bookmarkService;
   
    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }
    
    //...
    //...
    
    @Test
    void shouldGetBookmarkByIdSuccessfully() {
        CreateBookmarkCommand cmd = new CreateBookmarkCommand("SivaLabs blog", "https://sivalabs.in");
        BookmarkDTO bookmark = bookmarkService.create(cmd);

        given().contentType(ContentType.JSON)
                .when()
                .get("/api/bookmarks/{id}", bookmark.id())
                .then()
                .statusCode(200)
                .body("id", equalTo(bookmark.id()))
                .body("title", equalTo("SivaLabs blog"))
                .body("url", equalTo("https://sivalabs.in"))
                .body("createdAt", notNullValue())
                .body("updatedAt", nullValue());
    }

    @Test
    void shouldGet404WhenBookmarkNotExists() {
        Long nonExistingId = 99999L;
        given().contentType(ContentType.JSON)
                .when()
                .get("/api/bookmarks/{id}", nonExistingId)
                .then()
                .statusCode(404);
    }

    @Test
    void shouldDeleteBookmarkByIdSuccessfully() {
        CreateBookmarkCommand cmd = new CreateBookmarkCommand("SivaLabs blog", "https://sivalabs.in");
        BookmarkDTO bookmark = bookmarkService.create(cmd);

        given().contentType(ContentType.JSON)
                .when()
                .delete("/api/bookmarks/{id}", bookmark.id())
                .then()
                .statusCode(200);

        Optional<BookmarkDTO> optionalBookmark = bookmarkService.findById(bookmark.id());
        assertThat(optionalBookmark).isEmpty();
    }
}

现在,你可以使用 ./mvnw test 命令行运行测试。

异常处理 - 待续

你应该有注意,到目前为止我们还没有处理过出现异常的情况。例如,如果你尝试创建一个新书签,但没有传递必填字段(titleurl),或尝试删除一个不存在 id 的书签,那么你将得到类似下面的响应:

$ curl --location --request DELETE 'http://localhost:8080/api/bookmarks/99999'
{
    "timestamp": "2023-08-23T12:37:37.772+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "trace": "com.sivalabs.bookmarks.domain.BookmarkNotFoundException: Bookmark with id=17 not found 
              at com.sivalabs.bookmarks.domain.BookmarkNotFoundException.of(BookmarkNotFoundException.java:9)
              at com.sivalabs.bookmarks.domain.BookmarkService.lambda$delete$1(BookmarkService.java:65)
              at java.base/java.util.Optional.orElseThrow(Optional.java:403)
              at com.sivalabs.bookmarks.domain.BookmarkService.delete(BookmarkService.java:65)
              at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
              ...
              ...
              at java.base/java.lang.Thread.run(Thread.java:833)",
    "message": "Bookmark with id=17 not found",
    "path": "/api/bookmarks/17"
}

这是 Spring Boot 返回的默认异常响应。但是,我们一般都需要自定义异常响应。我们将在 下一章 探讨如何处理异常。

总结

在本章节中,我们学习了如何实现通过指定 id 来查找和删除资源的端点。

你可以在此 GitHub 仓库中找到本教程的示例代码。


参考:https://www.sivalabs.in/spring-boot-rest-api-best-practices-part-3/