@Path("/v1/myresource")
@ApplicationScoped
public class MyResource {
private final WebResource webResource = new WebResource();
@GET
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public Response getById(
@Context HttpServletRequest request,
@Context HttpServletResponse response,
@PathParam("id") String id
) {
// ALWAYS initialize request context
InitDataObject initData = new WebResource.InitBuilder(webResource)
.requestAndResponse(request, response)
.rejectWhenNoUser(true)
.init();
User user = initData.getUser();
try {
// Input validation
if (!UtilMethods.isSet(id)) {
return ResponseUtil.mapExceptionResponse(
new DotDataException("ID is required")
);
}
// Business logic
MyService service = APILocator.getMyService();
MyEntity entity = service.findById(id, user);
// Response
return Response.ok(new ResponseEntityView<>(entity)).build();
} catch (Exception e) {
Logger.error(this, "Error retrieving entity: " + e.getMessage(), e);
return ResponseUtil.mapExceptionResponse(e);
}
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public Response create(
@Context HttpServletRequest request,
@Context HttpServletResponse response,
MyEntityForm form
) {
InitDataObject initData = new WebResource.InitBuilder(webResource)
.requestAndResponse(request, response)
.rejectWhenNoUser(true)
.init();
User user = initData.getUser();
try {
// Form validation
if (!form.isValid()) {
return ResponseUtil.mapExceptionResponse(
new DotDataException("Invalid form data")
);
}
// Business logic
MyService service = APILocator.getMyService();
MyEntity entity = service.create(form, user);
return Response.ok(new ResponseEntityView<>(entity)).build();
} catch (Exception e) {
Logger.error(this, "Error creating entity: " + e.getMessage(), e);
return ResponseUtil.mapExceptionResponse(e);
}
}
}- WebResource.InitBuilder: ALWAYS initialize request context
- User context: Extract user from InitDataObject
- Input validation: Validate all parameters
- APILocator: Access services via APILocator
- Exception handling: Use ResponseUtil.mapExceptionResponse()
- Logging: Use Logger with proper context
- All new endpoints must use
/v1/prefix - Existing endpoints maintain current paths for backwards compatibility
- Version bumps only for breaking changes
@NoCache- Prevents caching of responses@ApplicationScoped- CDI scope for resource classes@Produces(MediaType.APPLICATION_JSON)- JSON response format@Consumes(MediaType.APPLICATION_JSON)- JSON request format (for POST/PUT)
// For authenticated endpoints
InitDataObject initData = new WebResource.InitBuilder(webResource)
.requestAndResponse(request, response)
.rejectWhenNoUser(true)
.init();
User user = initData.getUser();
// For public endpoints
InitDataObject initData = new WebResource.InitBuilder(webResource)
.requestAndResponse(request, response)
.rejectWhenNoUser(false)
.init();// Parameter validation
if (!UtilMethods.isSet(id) || !id.matches("^[a-zA-Z0-9\\-_]+$")) {
return ResponseUtil.mapExceptionResponse(
new DotDataException("Invalid ID format")
);
}
// Form validation
if (!form.isValid()) {
return ResponseUtil.mapExceptionResponse(
new DotDataException("Invalid form data: " + form.getValidationErrors())
);
}
// Business rule validation
if (!permissionAPI.doesUserHavePermission(entity, PermissionAPI.PERMISSION_READ, user)) {
return ResponseUtil.mapExceptionResponse(
new DotSecurityException("Access denied")
);
}// Success response
return Response.ok(new ResponseEntityView<>(entity)).build();
// Success with pagination
PaginationResult<MyEntity> result = service.findPaginated(query, user);
return Response.ok(new ResponseEntityView<>(result)).build();
// Error response
return ResponseUtil.mapExceptionResponse(exception);
// Created response
return Response.status(Response.Status.CREATED)
.entity(new ResponseEntityView<>(entity))
.build();Forms are inbound request-binding beans, not immutable value objects. They stay as mutable classes (Jackson binds them, then the resource validates and consumes them) — they do not use records or the Immutables library, so the record migration below does not apply to them.
@JsonIgnoreProperties(ignoreUnknown = true)
public class MyEntityForm {
private String name;
private String description;
private boolean enabled;
// Getters and setters
public boolean isValid() {
return UtilMethods.isSet(name) &&
name.length() <= 255 &&
(description == null || description.length() <= 1000);
}
public List<String> getValidationErrors() {
List<String> errors = new ArrayList<>();
if (!UtilMethods.isSet(name)) {
errors.add("Name is required");
}
if (name != null && name.length() > 255) {
errors.add("Name must be 255 characters or less");
}
if (description != null && description.length() > 1000) {
errors.add("Description must be 1000 characters or less");
}
return errors;
}
}REST view objects are Java records — not the Immutables @Value.Immutable Abstract* interface
style, and not the Immutable*-prefix class style from
JAVA_STANDARDS.md. The record is the public
type: there is no generated Abstract*/Immutable* indirection, no @Value.Style, and no
passAnnotations. Records are implicitly final and their components are final, so immutability
comes for free.
Key points when migrating a view object off Immutables:
- The record is the type. Drop
@Value.Immutable,@Value.Style, theAbstract*interface, and the@JsonDeserialize(as = …)redirect. Reference the record directly everywhere. @Schemagoes straight on the record and its components. Because the record is a concrete, directly-annotated type, the class-level@Schema(description=…)is emitted intoopenapi.yamlwithout thepassAnnotationsworkaround Immutables required. Component-level@Schemacarries over the same way it did on interface accessors.- Jackson uses native record support (Jackson 2.12+). Deserialization runs through the canonical
constructor — no
@JsonCreatorneeded. Gotcha: Jackson needs the constructor parameter names, so the module must be compiled with-parameters(preferred); otherwise fall back to@JsonPropertyon each component. - Builder is hand-written for now. Records don't generate a builder, and many view objects have
enough fields that positional construction is error-prone. Provide a small static nested
Builder. Going forward, prefer the@RecordBuilderannotation processor to generate the builder instead of maintaining it by hand — the call site (FileAssetView.builder()…build()) stays identical, so the swap is non-breaking.
@Schema(description = "File asset view returned after a save")
public record FileAssetView(
@Schema(description = "Asset identifier") String identifier,
@Schema(description = "File size in bytes") long fileSize
) {
// Hand-written builder for now; replace with @RecordBuilder on the record going forward.
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private String identifier;
private long fileSize;
public Builder identifier(String identifier) {
this.identifier = identifier;
return this;
}
public Builder fileSize(long fileSize) {
this.fileSize = fileSize;
return this;
}
public FileAssetView build() {
return new FileAssetView(identifier, fileSize);
}
}
}
// FileAssetView.builder().identifier(id).fileSize(size).build();The same record + hand-written-builder approach applies to immutable query / parameter objects
(e.g. MyEntityQuery used in pagination below). These are the other place the Immutables builder
was pulling its weight, and records cover them cleanly — set the builder defaults to match the
endpoint's @DefaultValues:
public record MyEntityQuery(int page, int perPage, String filter, String orderBy) {
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private int page = 1; // matches @DefaultValue("1")
private int perPage = 20; // matches @DefaultValue("20")
private String filter;
private String orderBy;
public Builder page(int page) { this.page = page; return this; }
public Builder perPage(int perPage) { this.perPage = perPage; return this; }
public Builder filter(String filter) { this.filter = filter; return this; }
public Builder orderBy(String orderBy) { this.orderBy = orderBy; return this; }
public MyEntityQuery build() {
return new MyEntityQuery(page, perPage, filter, orderBy);
}
}
}
// MyEntityQuery.builder().page(page).perPage(perPage).filter(filter).orderBy(orderBy).build();After changes, run ./mvnw compile -pl :dotcms-core --am -DskipTests, then confirm
git diff .../openapi/openapi.yaml is empty (or contains only the intended schema changes).
// Ensure user is authenticated (not anonymous)
if (user == null || user.isAnonymousUser()) {
return ResponseUtil.mapExceptionResponse(
new DotSecurityException("Authentication required")
);
}// Check specific permissions
if (!permissionAPI.doesUserHavePermission(entity, PermissionAPI.PERMISSION_READ, user)) {
return ResponseUtil.mapExceptionResponse(
new DotSecurityException("Read permission required")
);
}
// Check admin permissions
if (!user.isAdmin()) {
return ResponseUtil.mapExceptionResponse(
new DotSecurityException("Admin permission required")
);
}// Sanitize user input
String sanitizedInput = Xss.encodeForHTML(userInput);
// Validate against patterns
if (!userInput.matches("^[a-zA-Z0-9\\s\\-_\\.]+$")) {
return ResponseUtil.mapExceptionResponse(
new DotSecurityException("Invalid input format")
);
}try {
// Business logic
} catch (DotDataException e) {
Logger.error(this, "Data error: " + e.getMessage(), e);
return ResponseUtil.mapExceptionResponse(e);
} catch (DotSecurityException e) {
Logger.warn(this, "Security error: " + e.getMessage(), e);
return ResponseUtil.mapExceptionResponse(e);
} catch (Exception e) {
Logger.error(this, "Unexpected error: " + e.getMessage(), e);
return ResponseUtil.mapExceptionResponse(
new DotRuntimeException("Internal server error", e)
);
}// Custom validation error
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ResponseEntityView<>(
Collections.singletonMap("error", "Invalid request data")
))
.build();
// Not found error
return Response.status(Response.Status.NOT_FOUND)
.entity(new ResponseEntityView<>(
Collections.singletonMap("error", "Entity not found")
))
.build();- OpenAPI spec: Auto-generated at
/WEB-INF/openapi/openapi.yamlbyswagger-maven-pluginat Maven's compile phase (not a git hook) — CI verifies the committed file matches what the build produces - Merge strategy: Uses "ours" strategy for merge conflicts (
.gitattributes) - Record view objects:
@Schemaannotations on the record type and its components are picked up directly — nopassAnnotationsconfiguration is needed since records are concrete annotated types.
@Tag goes at the class level to group the resource's endpoints under one heading in the
generated docs; @RequestBody documents a POST/PUT payload the same way @Parameter documents a
path/query param:
@Path("/v1/myresource")
@Tag(name = "My Resource", description = "Manage my entities")
public class MyResource {
@Operation(summary = "Get entity by ID", description = "Retrieves a specific entity")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Success"),
@ApiResponse(responseCode = "404", description = "Entity not found"),
@ApiResponse(responseCode = "403", description = "Access denied")
})
@GET
@Path("/{id}")
public Response getById(@Parameter(description = "Entity ID") @PathParam("id") String id) {
// Implementation
}
@Operation(summary = "Create an entity")
@POST
public Response create(
@RequestBody(
description = "The entity to create",
required = true,
content = @Content(schema = @Schema(implementation = MyEntityForm.class))
)
MyEntityForm form
) {
// Implementation
}
}REST resource integration tests call the resource class directly — they don't go over real HTTP
(there's no io.restassured-style given()...when()...get(...) client in this codebase). This is
the real pattern, confirmed against TagResourceIntegrationTest.java:
@RunWith(DataProviderRunner.class)
public class MyResourceIntegrationTest extends IntegrationTestBase {
@BeforeClass
public static void prepare() throws Exception {
IntegrationTestInitService.getInstance().init();
}
@Test
public void testGetById_Success() throws Exception {
// Arrange
User user = new UserDataGen().nextPersisted();
MyEntity entity = new MyEntityDataGen().nextPersisted();
HttpServletRequest request = new MockAttributeRequest(
new MockHttpRequestIntegrationTest("localhost", "/api/v1/myresource").request());
HttpServletResponse response = new MockHttpResponse().response();
// Act — call the resource method directly
MyResource resource = new MyResource();
Response httpResponse = resource.getById(request, response, entity.getId());
// Assert
assertEquals(200, httpResponse.getStatus());
}
@Test
public void testGetById_NotFound() throws Exception {
HttpServletRequest request = new MockAttributeRequest(
new MockHttpRequestIntegrationTest("localhost", "/api/v1/myresource").request());
HttpServletResponse response = new MockHttpResponse().response();
MyResource resource = new MyResource();
Response httpResponse = resource.getById(request, response, "nonexistent");
assertEquals(404, httpResponse.getStatus());
}
}@GET
@Path("/")
public Response list(
@QueryParam("page") @DefaultValue("1") int page,
@QueryParam("per_page") @DefaultValue("20") int perPage,
@QueryParam("filter") String filter,
@QueryParam("orderBy") String orderBy,
@Context HttpServletRequest request,
@Context HttpServletResponse response
) {
InitDataObject initData = new WebResource.InitBuilder(webResource)
.requestAndResponse(request, response)
.rejectWhenNoUser(true)
.init();
User user = initData.getUser();
// Validate pagination parameters
if (page < 1 || perPage < 1 || perPage > 100) {
return ResponseUtil.mapExceptionResponse(
new DotDataException("Invalid pagination parameters")
);
}
// Build query (MyEntityQuery is now a record with a hand-written builder)
MyEntityQuery query = MyEntityQuery.builder()
.page(page)
.perPage(perPage)
.filter(filter)
.orderBy(orderBy)
.build();
// Execute query
MyService service = APILocator.getMyService();
PaginationResult<MyEntity> result = service.findPaginated(query, user);
return Response.ok(new ResponseEntityView<>(result)).build();
}@POST
@Path("/bulk")
public Response bulkOperation(
@Context HttpServletRequest request,
@Context HttpServletResponse response,
BulkOperationForm form
) {
InitDataObject initData = new WebResource.InitBuilder(webResource)
.requestAndResponse(request, response)
.rejectWhenNoUser(true)
.init();
User user = initData.getUser();
// Validate bulk operation
if (form.getIds().size() > 100) {
return ResponseUtil.mapExceptionResponse(
new DotDataException("Bulk operation limited to 100 items")
);
}
// Process in transaction
MyService service = APILocator.getMyService();
return LocalTransaction.wrapReturn(() -> {
List<MyEntity> results = new ArrayList<>();
for (String id : form.getIds()) {
MyEntity entity = service.processEntity(id, form.getOperation(), user);
results.add(entity);
}
return Response.ok(new ResponseEntityView<>(results)).build();
});
}- REST endpoints: Located in
com.dotcms.rest.*packages but the ones undercom.dotcms.rest.api.v*are considered the new endpoints, the ones directly undercom.dotcms.restare considered legacy endpoints. For example:com.dotcms.rest.ContentResourceis the legacy one andcom.dotcms.rest.api.v1.content.ContentResourceis the new one. - WebResource: Found in
com.dotcms.rest.WebResource - ResponseUtil: Located in
com.dotcms.rest.api.v1.authentication.ResponseUtil - Forms: Typically in same package as resource or
*.formsubpackage - View / query records: Same package as the resource or a
*.view/*.querysubpackage - Integration tests: Located in
dotcms-integrationmodule