mirror of https://github.com/halo-dev/halo
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
parent
73d43cae64
commit
3f7cb58370
|
@ -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 文档地址,并进行测试。
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue