Custom extensions allow you to define your own resource types in Halo CMS, extending the platform with domain-specific models. The Extension API provides full CRUD operations for any custom extension type.
Overview
Custom extensions follow the same API patterns as core extensions:
- Define extension classes with
@GVK annotation
- Automatic REST endpoint generation based on GVK
- Full CRUD support through unified API
- Same metadata, versioning, and lifecycle management
Creating a Custom Extension
1. Define the Extension Class
Create a Java class extending AbstractExtension with the @GVK annotation:
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@GVK(
group = "example.halo.run",
version = "v1alpha1",
kind = "Book",
plural = "books",
singular = "book"
)
public class Book extends AbstractExtension {
private BookSpec spec;
private BookStatus status;
}
@Data
public class BookSpec {
@Schema(requiredMode = REQUIRED, minLength = 1)
private String title;
@Schema(requiredMode = REQUIRED)
private String author;
private String isbn;
private String description;
@Schema(requiredMode = REQUIRED, defaultValue = "0")
private Integer price;
private List<String> tags;
}
@Data
public class BookStatus {
private String phase; // AVAILABLE, SOLD_OUT, DISCONTINUED
private Integer copiesSold;
private Instant lastOrderDate;
}
2. Register the Extension
In your plugin, register the extension type:
import org.springframework.stereotype.Component;
import run.halo.app.extension.SchemeManager;
import run.halo.app.plugin.BasePlugin;
import run.halo.app.plugin.PluginContext;
@Component
public class BookPlugin extends BasePlugin {
private final SchemeManager schemeManager;
public BookPlugin(PluginContext pluginContext, SchemeManager schemeManager) {
super(pluginContext);
this.schemeManager = schemeManager;
}
@Override
public void start() {
// Register the Book extension
schemeManager.register(Book.class);
}
@Override
public void stop() {
// Unregister on plugin stop
schemeManager.unregister(Book.class);
}
}
3. Access via REST API
Once registered, your custom extension is immediately available via REST API:
GET /apis/example.halo.run/v1alpha1/books
POST /apis/example.halo.run/v1alpha1/books
GET /apis/example.halo.run/v1alpha1/books/{name}
PUT /apis/example.halo.run/v1alpha1/books/{name}
PATCH /apis/example.halo.run/v1alpha1/books/{name}
DELETE /apis/example.halo.run/v1alpha1/books/{name}
CRUD Operations
Create a Custom Extension
Create a new book resource:
curl -X POST 'http://localhost:8091/apis/example.halo.run/v1alpha1/books' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"apiVersion": "example.halo.run/v1alpha1",
"kind": "Book",
"metadata": {
"name": "effective-java",
"labels": {
"category": "programming",
"language": "java"
}
},
"spec": {
"title": "Effective Java",
"author": "Joshua Bloch",
"isbn": "978-0134685991",
"description": "A comprehensive guide to Java best practices",
"price": 45,
"tags": ["java", "programming", "best-practices"]
}
}'
The unique identifier for the book
Initial version is 0, incremented on each update
metadata.creationTimestamp
ISO 8601 timestamp of creation
List Custom Extensions
List all books with filtering and pagination:
curl -X GET 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?labelSelector=category=programming&size=20&page=0' \
-H 'Authorization: Bearer <token>'
Number of items per page (0 = no pagination)
Filter by labels (e.g., category=programming, language!=python)
Filter by fields (e.g., metadata.name==effective-java)
Sort by fields (e.g., metadata.creationTimestamp,desc)
Response:
{
"page": 0,
"size": 20,
"total": 1,
"totalPages": 1,
"first": true,
"last": true,
"hasNext": false,
"hasPrevious": false,
"items": [
{
"apiVersion": "example.halo.run/v1alpha1",
"kind": "Book",
"metadata": {
"name": "effective-java",
"version": 0,
"creationTimestamp": "2024-01-15T10:30:00Z",
"labels": {
"category": "programming",
"language": "java"
}
},
"spec": {
"title": "Effective Java",
"author": "Joshua Bloch",
"isbn": "978-0134685991",
"price": 45,
"tags": ["java", "programming", "best-practices"]
}
}
]
}
Get a Custom Extension
Retrieve a single book by name:
curl -X GET 'http://localhost:8091/apis/example.halo.run/v1alpha1/books/effective-java' \
-H 'Authorization: Bearer <token>'
Response:
{
"apiVersion": "example.halo.run/v1alpha1",
"kind": "Book",
"metadata": {
"name": "effective-java",
"version": 2,
"creationTimestamp": "2024-01-15T10:30:00Z"
},
"spec": {
"title": "Effective Java (3rd Edition)",
"author": "Joshua Bloch",
"isbn": "978-0134685991",
"price": 50
},
"status": {
"phase": "AVAILABLE",
"copiesSold": 127
}
}
Update a Custom Extension
Update the entire resource (full replacement):
curl -X PUT 'http://localhost:8091/apis/example.halo.run/v1alpha1/books/effective-java' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"apiVersion": "example.halo.run/v1alpha1",
"kind": "Book",
"metadata": {
"name": "effective-java",
"version": 2,
"labels": {
"category": "programming",
"featured": "true"
}
},
"spec": {
"title": "Effective Java (3rd Edition)",
"author": "Joshua Bloch",
"isbn": "978-0134685991",
"price": 50
}
}'
You must include the current metadata.version field. If the version doesn’t match (due to concurrent updates), you’ll receive a 409 Conflict error.
Patch a Custom Extension
Partially update specific fields using JSON Patch:
curl -X PATCH 'http://localhost:8091/apis/example.halo.run/v1alpha1/books/effective-java' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json-patch+json' \
-d '[
{
"op": "replace",
"path": "/spec/price",
"value": 55
},
{
"op": "add",
"path": "/metadata/labels/bestseller",
"value": "true"
}
]'
Supported Operations:
add: Add a new field or array element
remove: Remove a field or array element
replace: Replace a field’s value
copy: Copy a value from one path to another
move: Move a value from one path to another
test: Test that a value matches
Delete a Custom Extension
Delete a book resource:
curl -X DELETE 'http://localhost:8091/apis/example.halo.run/v1alpha1/books/effective-java' \
-H 'Authorization: Bearer <token>'
If the resource has finalizers, it will be marked for deletion (with deletionTimestamp set) but not actually removed until all finalizers are cleared.
Advanced Features
Using Labels for Filtering
Labels are indexed and optimized for querying:
# Single label selector
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?labelSelector=category=programming'
# Multiple label selectors (AND)
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?labelSelector=category=programming&labelSelector=featured=true'
# Not equals
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?labelSelector=category!=fiction'
# Label exists
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?labelSelector=bestseller'
# Label doesn't exist
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?labelSelector=!discontinued'
Using Field Selectors
Filter by specific field values:
# Filter by name
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?fieldSelector=metadata.name==effective-java'
# Filter by creation time (greater than)
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?fieldSelector=metadata.creationTimestamp>2024-01-01T00:00:00Z'
Sorting Results
Sort by one or more fields:
# Sort by creation time (descending)
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?sort=metadata.creationTimestamp,desc'
# Multiple sort fields
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?sort=spec.price,desc&sort=metadata.name,asc'
Custom Indexing
For frequently queried fields, create custom indexes:
import run.halo.app.extension.index.IndexSpec;
import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute;
@Component
public class BookIndexConfiguration {
@Autowired
public void configureIndexes(IndexBuilderRegistry registry) {
registry.add(IndexSpec.builder(Book.class)
.add(IndexSpec.IndexEntry.of(
"spec.author",
simpleAttribute(Book.class, book -> book.getSpec().getAuthor())
))
.add(IndexSpec.IndexEntry.of(
"spec.price",
simpleAttribute(Book.class, book -> book.getSpec().getPrice())
))
.build()
);
}
}
Now you can use field selectors on these indexed fields:
curl 'http://localhost:8091/apis/example.halo.run/v1alpha1/books?fieldSelector=spec.author==Joshua+Bloch'
Validation
Use schema annotations to add validation:
import io.swagger.v3.oas.annotations.media.Schema;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
@Data
public class BookSpec {
@Schema(requiredMode = REQUIRED, minLength = 1, maxLength = 255)
private String title;
@Schema(requiredMode = REQUIRED, pattern = "^[A-Z][a-z]+ [A-Z][a-z]+$")
private String author;
@Schema(pattern = "^(97[89])?\\d{9}(\\d|X)$")
private String isbn;
@Schema(minimum = "0", maximum = "999999")
private Integer price;
@Schema(format = "email")
private String contactEmail;
}
Validation errors return 422 Unprocessable Entity:
{
"status": 422,
"error": "Unprocessable Entity",
"message": "Validation failed",
"errors": [
{
"field": "spec.title",
"message": "must not be empty"
},
{
"field": "spec.price",
"message": "must be between 0 and 999999"
}
]
}
Lifecycle Management
Using Finalizers
Prevent deletion until cleanup is complete:
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.Reconciler;
@Component
public class BookReconciler implements Reconciler<BookReconciler.Request> {
private static final String FINALIZER_NAME = "book.example.halo.run/cleanup";
@Override
public Result reconcile(Request request) {
return client.fetch(Book.class, request.name())
.flatMap(book -> {
if (book.getMetadata().getDeletionTimestamp() != null) {
// Book is being deleted
return cleanupBook(book)
.then(removeFinalizer(book));
} else {
// Ensure finalizer is present
return addFinalizerIfNeeded(book);
}
})
.retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))
.map(book -> new Result(false, null))
.defaultIfEmpty(new Result(false, null));
}
private Mono<Book> addFinalizerIfNeeded(Book book) {
var finalizers = book.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
return Mono.just(book);
}
if (finalizers == null) {
finalizers = new HashSet<>();
book.getMetadata().setFinalizers(finalizers);
}
finalizers.add(FINALIZER_NAME);
return client.update(book);
}
private Mono<Void> cleanupBook(Book book) {
// Perform cleanup (delete related resources, notify services, etc.)
return Mono.empty();
}
private Mono<Book> removeFinalizer(Book book) {
var finalizers = book.getMetadata().getFinalizers();
if (finalizers != null) {
finalizers.remove(FINALIZER_NAME);
return client.update(book);
}
return Mono.just(book);
}
}
Watch for Changes
React to extension changes in real-time:
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Watcher;
@Component
public class BookWatcher {
private final ReactiveExtensionClient client;
@PostConstruct
public void watchBooks() {
client.watch(Book.class)
.subscribe(event -> {
switch (event.getType()) {
case ADDED:
System.out.println("New book added: " + event.getObject().getMetadata().getName());
break;
case MODIFIED:
System.out.println("Book updated: " + event.getObject().getMetadata().getName());
break;
case DELETED:
System.out.println("Book deleted: " + event.getObject().getMetadata().getName());
break;
}
});
}
}
Best Practices
Naming Conventions
- Group names: Use reverse-DNS notation (e.g.,
example.halo.run, mycompany.halo.run)
- Kind names: Use singular, PascalCase (e.g.,
Book, Order, ShoppingCart)
- Plural names: Use lowercase (e.g.,
books, orders, shoppingcarts)
- Resource names: Use lowercase with hyphens (e.g.,
effective-java, order-12345)
API Versioning
- Start with
v1alpha1 for experimental APIs
- Progress to
v1beta1 when API is stable but may change
- Use
v1 for stable, production-ready APIs
- Maintain backward compatibility or provide migration paths
Labels:
- Use for filtering and selection
- Keep values simple (no complex data)
- Index frequently queried labels
- Examples:
category=programming, featured=true, status=active
Annotations:
- Use for non-identifying metadata
- Can store complex data (JSON)
- Not indexed by default
- Examples: configuration, last sync time, external IDs
- Use pagination for list operations with large result sets
- Index frequently queried fields using custom indexes
- Use labels instead of field selectors when possible
- Limit watch usage to necessary resources only
- Batch operations when creating/updating multiple resources
Error Handling
Common HTTP Status Codes
- 200 OK: Successful GET, PUT, PATCH
- 201 Created: Successful POST
- 204 No Content: Successful DELETE
- 400 Bad Request: Invalid JSON or missing required fields
- 401 Unauthorized: Missing authentication
- 403 Forbidden: Insufficient permissions
- 404 Not Found: Resource doesn’t exist
- 409 Conflict: Version mismatch (optimistic locking)
- 422 Unprocessable Entity: Validation errors
- 500 Internal Server Error: Server-side error
Handling Version Conflicts
When concurrent updates occur:
# First update succeeds
curl -X PUT '.../books/effective-java' -d '{"metadata":{"version":1},...}'
# Returns: version 2
# Second update with stale version fails
curl -X PUT '.../books/effective-java' -d '{"metadata":{"version":1},...}'
# Returns: 409 Conflict
Solution: Fetch latest version and retry:
public Mono<Book> updateBook(String name, Function<Book, Book> updater) {
return client.fetch(Book.class, name)
.flatMap(book -> {
Book updated = updater.apply(book);
return client.update(updated);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(throwable -> throwable instanceof OptimisticLockingFailureException));
}
Security Considerations
Permission Model
Extension access is controlled through Halo’s RBAC system:
apiVersion: v1alpha1
kind: Role
metadata:
name: book-admin
rules:
- apiGroups: ["example.halo.run"]
resources: ["books"]
verbs: ["*"]
- apiGroups: ["example.halo.run"]
resources: ["books/status"]
verbs: ["get", "update"]
Always validate user input:
- Use schema annotations for basic validation
- Implement custom validators for complex rules
- Sanitize data before storage
- Validate relationships (e.g., referenced resources exist)
Sensitive Data
For sensitive information:
- Use
Secret resources instead of storing in extension fields
- Reference secrets by name in your extension
- Never expose sensitive data in status fields
- Use annotations for internal metadata only
Next Steps