Files
Serendipity edbf78f236 feat: 初始化 Halo 暗色模式插件
- Halo Plugin 后端(Java/Gradle),含 DarkModePlugin 主类和测试
- Vue 3 + TypeScript 前端 UI,包含主题切换组件和设置页面
- 暗色模式 CSS 变量和覆盖样式(布局/编辑器/表单/滚动条等)
- 设计文档和调查文档
- Halo 插件/主题开发 Agent Skills

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 21:03:00 +08:00

6.9 KiB

Custom APIs

Halo plugins can define custom APIs in addition to auto-generated CRUD APIs.

Source references (Halo main branch):

API Group Conventions

Scope URL Prefix Example
Console /apis/console.api.{group}/{version}/... /apis/console.api.my-plugin.halo.run/v1alpha1/persons
UC (User Center) /apis/uc.api.{group}/{version}/... /apis/uc.api.my-plugin.halo.run/v1alpha1/persons
Public (theme) /apis/api.{group}/{version}/... /apis/api.my-plugin.halo.run/v1alpha1/persons

{group} is the group value from the @GVK annotation.

Implements run.halo.app.core.extension.endpoint.CustomEndpoint:

import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;

import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
import static org.springframework.http.MediaType.APPLICATION_JSON;

@Component
public class PersonEndpoint implements CustomEndpoint {

    @Override
    public RouterFunction<ServerResponse> endpoint() {
        return route()
            .GET("/persons/{name}", accept(APPLICATION_JSON), this::getPerson)
            .POST("/persons", this::createPerson)
            .build();
    }

    private Mono<ServerResponse> getPerson(ServerRequest request) {
        String name = request.pathVariable("name");
        return ServerResponse.ok().bodyValue("Hello, " + name);
    }

    private Mono<ServerResponse> createPerson(ServerRequest request) {
        // ...
        return ServerResponse.ok().build();
    }

    @Override
    public GroupVersion groupVersion() {
        return new GroupVersion("console.api.my-plugin.halo.run", "v1alpha1");
    }
}

The endpoint() paths are automatically prefixed with /apis/{group}/{version}/.

Method 2: MVC-style @Controller

import run.halo.app.plugin.ApiVersion;

@ApiVersion("my-plugin.halo.run/v1alpha1")
@RestController
@RequiredArgsConstructor
@RequestMapping("/persons")
public class PersonController {
    private final PersonService personService;

    @GetMapping("/{name}")
    public Mono<Person> getPerson(@PathVariable("name") String name) {
        return personService.getPerson(name);
    }
}

⚠️ @ApiVersion is required — controllers without it will not be registered.

OpenAPI Documentation

Use SpringdocRouteBuilder to document Functional Endpoints:

import run.halo.app.infra.utils.SpringdocRouteBuilder;

@Override
public RouterFunction<ServerResponse> endpoint() {
    final var tag = "PersonV1alpha1Console";
    return SpringdocRouteBuilder.route()
        .GET("/persons", this::listPersons,
            builder -> builder
                .operationId("ListPersons")
                .description("List all persons")
                .tag(tag)
                .response(responseBuilder()
                    .implementation(ListResult.generateGenericClass(Person.class))
                )
        )
        .build();
}

Tag naming convention: {Kind}{Version}{Scope} e.g., PersonV1alpha1Console.

Query Parameters

Extend run.halo.app.extension.router.SortableRequest for list queries:

import static run.halo.app.extension.index.query.Queries.equal;
import static run.halo.app.extension.index.query.Queries.contains;
import static run.halo.app.extension.index.query.Queries.or;

public class PersonQuery extends SortableRequest {
    public PersonQuery(ServerWebExchange exchange) {
        super(exchange);
    }

    public String getKeyword() {
        return queryParams.getFirst("keyword");
    }

    @Override
    public ListOptions toListOptions() {
        var keyword = getKeyword();
        if (StringUtils.hasText(keyword)) {
            return ListOptions.builder(super.toListOptions())
                .fieldQuery(or(
                    equal("metadata.name", keyword),
                    contains("spec.name", keyword)
                ))
                .build();
        }
        return super.toListOptions();
    }
}

Usage:

public Mono<ListResult<Person>> list(ServerRequest request) {
    var query = new PersonQuery(request.exchange());
    return client.listBy(Person.class, query.toListOptions(), query.toPageRequest());
}

Request Validation (Bean Validation)

public class PersonParam {
    @NotNull
    @Size(max = 64)
    private String name;

    @Min(0)
    private int age;
}

Enable validator:

@Configuration
public class PluginConfig {
    @Bean
    public LocalValidatorFactoryBean validator() {
        return new LocalValidatorFactoryBean();
    }
}

Inject and use:

@Component
@RequiredArgsConstructor
public class PersonEndpoint implements CustomEndpoint {
    private final Validator validator;

    private Mono<ServerResponse> createPerson(ServerRequest request) {
        return request.bodyToMono(PersonParam.class)
            .doOnNext(this::validate)
            .flatMap(person -> /* ... */);
    }

    private void validate(PersonParam param) {
        var result = new BeanPropertyBindingResult(param, "person");
        validator.validate(param, result);
        if (result.hasErrors()) {
            throw new RequestBodyValidationException(result);
        }
    }
}

Swagger Groups

API docs at /swagger-ui.html are grouped as:

Group Content
Aggregated API V1alpha1 All APIs combined
Extension API V1alpha1 Auto-generated CRUD APIs
Console API V1alpha1 Console custom APIs
User-center API V1alpha1 UC custom APIs
Public API V1alpha1 Public/theme custom APIs