Add support for custom endpoints (#2223)

* Support to add custom endpoints

Signed-off-by: johnniang <johnniang@fastmail.com>

* Update docs/developer-guide/custom-endpoint.md

Co-authored-by: guqing <38999863+guqing@users.noreply.github.com>

Co-authored-by: Ryan Wang <i@ryanc.cc>
Co-authored-by: guqing <38999863+guqing@users.noreply.github.com>
pull/2227/head
John Niang 2022-07-08 12:40:13 +08:00 committed by GitHub
parent 73d43cae64
commit 3f7cb58370
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 231 additions and 0 deletions

View File

@ -0,0 +1,50 @@
# 系统自定义 API
系统自定义 API 是一组特殊的 API因为自定义模型 API 无法满足要求,需要开发者自己实现。
但是系统自定义 API 有一个统一的前缀:`/apis/api.halo.run/v1alpha1/`,剩余的部分可随意定义。
## 如何在系统中创建一个系统自定义 API
1. 实现 `run.halo.app.core.extension.endpoint.CustomEndpoint` 接口
2. 将实现类设置为 Spring Bean
关于用户的自定义 API 实现类如下:
```java
@Component
public class UserEndpoint implements CustomEndpoint {
private final ExtensionClient client;
public UserEndpoint(ExtensionClient client) {
this.client = client;
}
Mono<ServerResponse> me(ServerRequest request) {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> {
var name = ctx.getAuthentication().getName();
return client.fetch(User.class, name)
.orElseThrow(() -> new ExtensionNotFoundException(name));
})
.flatMap(user -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(user));
}
@Override
public RouterFunction<ServerResponse> endpoint() {
return SpringdocRouteBuilder.route()
.GET("/users/-", this::me, builder -> builder.operationId("GetCurrentUserDetail")
.description("Get current user detail")
.tag("api.halo.run/v1alpha1/User")
.response(responseBuilder().implementation(User.class)))
// 这里可添加其他自定义 API
.build();
}
}
```
这样我们就可以启动 Halo访问 Swagger UI 文档地址,并进行测试。

View File

@ -2,6 +2,7 @@ package run.halo.app.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.codec.CodecConfigurer;
@ -12,9 +13,12 @@ import org.springframework.http.codec.json.Jackson2JsonEncoder;
import org.springframework.lang.NonNull;
import org.springframework.web.reactive.config.EnableWebFlux;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolutionResultHandler;
import org.springframework.web.reactive.result.view.ViewResolver;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.extension.endpoint.CustomEndpointsBuilder;
@Configuration
@EnableWebFlux
@ -52,4 +56,12 @@ public class WebFluxConfig implements WebFluxConfigurer {
configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(objectMapper));
configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(objectMapper));
}
@Bean
RouterFunction<ServerResponse> customEndpoints(ApplicationContext context) {
var builder = new CustomEndpointsBuilder();
context.getBeansOfType(CustomEndpoint.class).values()
.forEach(customEndpoint -> builder.add(customEndpoint.endpoint()));
return builder.build();
}
}

View File

@ -0,0 +1,16 @@
package run.halo.app.core.extension.endpoint;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* RouterFunction provider for custom endpoints.
*
* @author johnniang
*/
@FunctionalInterface
public interface CustomEndpoint {
RouterFunction<ServerResponse> endpoint();
}

View File

@ -0,0 +1,33 @@
package run.halo.app.core.extension.endpoint;
import java.util.LinkedList;
import java.util.List;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
public class CustomEndpointsBuilder {
private final List<RouterFunction<ServerResponse>> routerFunctions;
public CustomEndpointsBuilder() {
routerFunctions = new LinkedList<>();
}
public CustomEndpointsBuilder add(RouterFunction<ServerResponse> routerFunction) {
routerFunctions.add(routerFunction);
return this;
}
public RouterFunction<ServerResponse> build() {
return SpringdocRouteBuilder.route()
.nest(RequestPredicates.path("/apis/api.halo.run/v1alpha1"),
() -> routerFunctions.stream().reduce(RouterFunction::and).orElse(null),
builder -> builder
.operationId("CustomEndpoints")
.description("Custom endpoints")
.tag("api.halo.run/v1alpha1/CustomEndpoint"))
.build();
}
}

View File

@ -0,0 +1,47 @@
package run.halo.app.core.extension.endpoint;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
@Component
public class UserEndpoint implements CustomEndpoint {
private final ExtensionClient client;
public UserEndpoint(ExtensionClient client) {
this.client = client;
}
Mono<ServerResponse> me(ServerRequest request) {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> {
var name = ctx.getAuthentication().getName();
return client.fetch(User.class, name)
.orElseThrow(() -> new ExtensionNotFoundException(name));
})
.flatMap(user -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(user));
}
@Override
public RouterFunction<ServerResponse> endpoint() {
return SpringdocRouteBuilder.route()
.GET("/users/-", this::me, builder -> builder.operationId("GetCurrentUserDetail")
.description("Get current user detail")
.tag("api.halo.run/v1alpha1/User")
.response(responseBuilder().implementation(User.class)))
.build();
}
}

View File

@ -0,0 +1,73 @@
package run.halo.app.core.extension.endpoint;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.reactive.server.WebTestClient;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
@SpringBootTest
@AutoConfigureWebTestClient
class UserEndpointTest {
@Autowired
WebTestClient webClient;
@MockBean
RoleService roleService;
@MockBean
ExtensionClient client;
@BeforeEach
void setUp() {
// disable authorization
var rule = new Role.PolicyRule.Builder()
.apiGroups("*")
.resources("*")
.verbs("*")
.build();
var role = new Role();
role.setRules(List.of(rule));
when(roleService.getRole(anyString())).thenReturn(role);
}
@Test
@WithMockUser("fake-user")
void shouldResponseErrorIfUserNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.empty());
webClient.get().uri("/apis/api.halo.run/v1alpha1/users/-")
.exchange()
.expectStatus().is5xxServerError();
}
@Test
@WithMockUser("fake-user")
void shouldGetCurrentUserDetail() {
var metadata = new Metadata();
metadata.setName("fake-user");
var user = new User();
user.setMetadata(metadata);
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(user));
webClient.get().uri("/apis/api.halo.run/v1alpha1/users/-")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody(User.class)
.isEqualTo(user);
}
}