From d44607aa72cf6c0e3fe740b1dd8ac2f24c4a09a2 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 07:38:13 -0500 Subject: [PATCH 01/15] Migrate from Jackson 2 to Jackson 3 by updating imports and exception handling across multiple classes --- .../OneTimeRemoteAccountServiceAPI.java | 9 +- .../JsonUsernamePasswordAuthFilter.java | 2 +- .../AbstractCrudServiceRestController.java | 7 +- .../util/BasicEntityJsonDeserializer.java | 15 ++-- .../util/BasicEntityJsonSerializer.java | 41 ++++++--- .../java/tools/dynamia/viewers/JsonView.java | 24 ++--- .../JsonViewDescriptorDeserializer.java | 23 +++-- .../viewers/JsonViewDescriptorSerializer.java | 87 +++++++++++++------ 8 files changed, 130 insertions(+), 78 deletions(-) diff --git a/extensions/saas/sources/remote/src/main/java/tools/dynamia/modules/saas/remote/OneTimeRemoteAccountServiceAPI.java b/extensions/saas/sources/remote/src/main/java/tools/dynamia/modules/saas/remote/OneTimeRemoteAccountServiceAPI.java index ca9bbb6e..46766544 100644 --- a/extensions/saas/sources/remote/src/main/java/tools/dynamia/modules/saas/remote/OneTimeRemoteAccountServiceAPI.java +++ b/extensions/saas/sources/remote/src/main/java/tools/dynamia/modules/saas/remote/OneTimeRemoteAccountServiceAPI.java @@ -17,14 +17,15 @@ package tools.dynamia.modules.saas.remote; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; + import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import tools.dynamia.commons.DateTimeUtils; import tools.dynamia.commons.StringUtils; import tools.dynamia.modules.saas.api.dto.AccountDTO; import tools.dynamia.modules.saas.api.enums.AccountStatus; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.Base64; @@ -100,7 +101,7 @@ protected void syncAccountInfo() { if (accountDTO != null) { accountDTO.setMaxUsers(maxUsers); } - } catch (IOException e) { + } catch (JacksonException e) { e.printStackTrace(); } } @@ -115,7 +116,7 @@ protected void syncAccountInfo() { pref.put(key, accountJson); pref.putLong(key2, System.currentTimeMillis()); pref.sync(); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { e.printStackTrace(); } catch (BackingStoreException e) { e.printStackTrace(); diff --git a/extensions/security/sources/core/src/main/java/tools/dynamia/modules/security/JsonUsernamePasswordAuthFilter.java b/extensions/security/sources/core/src/main/java/tools/dynamia/modules/security/JsonUsernamePasswordAuthFilter.java index 20121e71..bc454101 100644 --- a/extensions/security/sources/core/src/main/java/tools/dynamia/modules/security/JsonUsernamePasswordAuthFilter.java +++ b/extensions/security/sources/core/src/main/java/tools/dynamia/modules/security/JsonUsernamePasswordAuthFilter.java @@ -1,7 +1,6 @@ package tools.dynamia.modules.security; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.MediaType; @@ -19,6 +18,7 @@ import tools.dynamia.commons.StringPojoParser; import tools.dynamia.modules.security.domain.User; import tools.dynamia.modules.security.listeners.SpringSecurtyApplicationListener; +import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/AbstractCrudServiceRestController.java b/platform/app/src/main/java/tools/dynamia/app/controllers/AbstractCrudServiceRestController.java index f2628009..b8e07bec 100644 --- a/platform/app/src/main/java/tools/dynamia/app/controllers/AbstractCrudServiceRestController.java +++ b/platform/app/src/main/java/tools/dynamia/app/controllers/AbstractCrudServiceRestController.java @@ -1,7 +1,6 @@ package tools.dynamia.app.controllers; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.json.JsonMapper; + import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -9,6 +8,8 @@ import tools.dynamia.domain.ValidationError; import tools.dynamia.domain.query.QueryParameters; import tools.dynamia.domain.services.CrudService; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; import java.io.Serializable; import java.util.List; @@ -137,7 +138,7 @@ private Object parseJson(String className, String json) { Class entityClass = loadClass(className); try { return mapper.readValue(json, entityClass); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new ValidationError("Error parsing json for entity class " + className, e); } } diff --git a/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonDeserializer.java b/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonDeserializer.java index 7e995dc1..b0c208a7 100644 --- a/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonDeserializer.java +++ b/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonDeserializer.java @@ -1,14 +1,13 @@ package tools.dynamia.domain.util; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + import tools.dynamia.commons.ObjectOperations; import tools.dynamia.commons.URLable; import tools.dynamia.domain.AbstractEntity; - -import java.io.IOException; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.deser.std.StdDeserializer; @SuppressWarnings("ALL") public class BasicEntityJsonDeserializer extends StdDeserializer { @@ -23,9 +22,9 @@ public BasicEntityJsonDeserializer(Class vc) { @SuppressWarnings("unchecked") @Override - public AbstractEntity deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public AbstractEntity deserialize(JsonParser p, DeserializationContext ctxt){ - JsonNode node = p.getCodec().readTree(p); + JsonNode node = p.readValueAsTree(); String className = node.get(BasicEntityJsonSerializer.CLASS).asText(); String name = node.get(BasicEntityJsonSerializer.NAME).asText(); diff --git a/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonSerializer.java b/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonSerializer.java index 6f23982d..cc56e303 100644 --- a/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonSerializer.java +++ b/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonSerializer.java @@ -1,34 +1,49 @@ package tools.dynamia.domain.util; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; + import tools.dynamia.commons.URLable; import tools.dynamia.domain.AbstractEntity; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.BeanProperty; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.introspect.AnnotatedMember; +import tools.jackson.databind.jsontype.TypeSerializer; +import tools.jackson.databind.ser.jackson.JsonValueSerializer; -import java.io.IOException; +import java.util.Set; -public class BasicEntityJsonSerializer extends JsonSerializer { +public class BasicEntityJsonSerializer extends JsonValueSerializer { public static final String NAME = "name"; public static final String ID = "id"; public static final String CLASS = "class"; public static final String URL = "url"; + + public BasicEntityJsonSerializer(JavaType nominalType, JavaType valueType, boolean staticTyping, TypeSerializer vts, ValueSerializer ser, AnnotatedMember accessor, Set ignoredProperties) { + super(nominalType, valueType, staticTyping, vts, ser, accessor, ignoredProperties); + } + + public BasicEntityJsonSerializer(JsonValueSerializer src, BeanProperty property, TypeSerializer vts, ValueSerializer ser, boolean forceTypeInfo) { + super(src, property, vts, ser, forceTypeInfo); + } + @Override - public void serialize(AbstractEntity value, JsonGenerator gen, SerializerProvider serializers) throws IOException { - if (value != null && value.getId() != null) { + public void serialize(Object value, JsonGenerator gen, SerializationContext context) { + if (value instanceof AbstractEntity entity && entity.getId() != null) { gen.writeStartObject(); - gen.writeStringField(CLASS, value.getClass().getName()); - gen.writeStringField(NAME, value.toName()); - if (value.getId() instanceof Long) { - gen.writeNumberField(ID, (Long) value.getId()); + gen.writeStringProperty(CLASS, entity.getClass().getName()); + gen.writeStringProperty(NAME, entity.toName()); + if (entity.getId() instanceof Long) { + gen.writeNumberProperty(ID, (Long) entity.getId()); } else { - gen.writeStringField(ID, value.getId().toString()); + gen.writeStringProperty(ID, entity.getId().toString()); } if (value instanceof URLable) { - gen.writeStringField(URL, ((URLable) value).toURL()); + gen.writeStringProperty(URL, ((URLable) value).toURL()); } diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonView.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonView.java index 279e9141..36249c6e 100644 --- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonView.java +++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonView.java @@ -17,12 +17,11 @@ package tools.dynamia.viewers; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import tools.dynamia.commons.StringPojoParser; -import java.io.IOException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; @SuppressWarnings("unchecked") public class JsonView implements View { @@ -82,7 +81,7 @@ public String renderJson() { try { var mapper = getJsonMapper(); return mapper.writeValueAsString(value); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new ViewRendererException("Exception rendering json view of " + value + " with descriptor " + viewDescriptor, e); } } @@ -95,7 +94,7 @@ public void parse(String json) { try { //noinspection unchecked value = (T) getJsonMapper().readValue(json, viewDescriptor.getBeanClass()); - } catch (IOException e) { + } catch (JacksonException e) { throw new ViewRendererException("Error parsing json to object", e); } @@ -103,12 +102,17 @@ public void parse(String json) { private JsonMapper getJsonMapper() { if (mapper == null) { - mapper = StringPojoParser.createJsonMapper(); SimpleModule module = new SimpleModule(); module.addSerializer(viewDescriptor.getBeanClass(), new JsonViewDescriptorSerializer(viewDescriptor)); - //noinspection unchecked module.addDeserializer((Class) viewDescriptor.getBeanClass(), new JsonViewDescriptorDeserializer(viewDescriptor)); - mapper.registerModule(module); + + mapper = JsonMapper.builder() + .enable(SerializationFeature.INDENT_OUTPUT) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .addModule(module) + .build(); + + } return mapper; } diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java index c221d893..c02d81f3 100644 --- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java +++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java @@ -17,11 +17,7 @@ package tools.dynamia.viewers; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.deser.std.StdDeserializer; -import com.fasterxml.jackson.databind.util.StdDateFormat; + import tools.dynamia.commons.ObjectOperations; import tools.dynamia.commons.logger.LoggingService; import tools.dynamia.commons.logger.SLF4JLoggingService; @@ -29,14 +25,14 @@ import tools.dynamia.commons.reflect.ReflectionException; import tools.dynamia.domain.util.DomainUtils; import tools.dynamia.viewers.util.Viewers; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.util.StdDateFormat; -import java.io.IOException; import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; public class JsonViewDescriptorDeserializer extends StdDeserializer { @@ -56,8 +52,9 @@ public JsonViewDescriptorDeserializer(ViewDescriptor viewDescriptor, Class { private final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); private final DateFormat timeFormat = new SimpleDateFormat("HH:mm:ss"); private final DateFormat basicDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); + + private final static LoggingService LOGGER = new SLF4JLoggingService(JsonViewDescriptorSerializer.class); public JsonViewDescriptorSerializer(ViewDescriptor viewDescriptor) { @@ -60,8 +67,9 @@ public JsonViewDescriptorSerializer(ViewDescriptor viewDescriptor, Class this.viewDescriptor = viewDescriptor; } + @Override - public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException { + public void serialize(Object value, JsonGenerator gen, SerializationContext provider) { if (viewDescriptor == null) { return; } @@ -70,7 +78,7 @@ public void serialize(Object value, JsonGenerator gen, SerializerProvider provid if (id != null) { writeField(gen, "id", id); } - // writeField(gen, "name", value.toString()); + // writeField(gen, "name", value.toString()); for (Field field : Viewers.getFields(viewDescriptor)) { PropertyInfo fieldInfo = field.getPropertyInfo(); @@ -92,7 +100,7 @@ public void serialize(Object value, JsonGenerator gen, SerializerProvider provid } if (collection != null && !collection.isEmpty()) { - gen.writeArrayFieldStart(field.getName()); + gen.writeStartArray(field.getName()); ViewDescriptor collectionDescriptor = getFieldViewDescriptor(fieldInfo.getGenericType()); JsonViewDescriptorSerializer collectionSerializer = new JsonViewDescriptorSerializer(collectionDescriptor); for (Object item : collection) { @@ -110,7 +118,7 @@ public void serialize(Object value, JsonGenerator gen, SerializerProvider provid EntityReferenceRepository repository = DomainUtils.getEntityReferenceRepositoryByAlias(reference.value()); @SuppressWarnings("unchecked") EntityReference entityReference = repository.load((Serializable) fieldValue); if (entityReference != null) { - gen.writeObjectFieldStart(fieldName); + gen.writeStartObject(fieldName); writeField(gen, "id", entityReference.getId()); writeField(gen, "name", entityReference.getName()); gen.writeEndObject(); @@ -144,54 +152,55 @@ private ViewDescriptor getFieldViewDescriptor(Class clazz) { return descriptor; } - private void writeField(JsonGenerator gen, String fieldName, Object fieldValue) throws IOException { + private void writeField(JsonGenerator gen, String fieldName, Object fieldValue) { if (fieldValue instanceof Integer) { - gen.writeNumberField(fieldName, (Integer) fieldValue); + gen.writeNumberProperty(fieldName, (Integer) fieldValue); } else if (fieldValue instanceof Long) { - gen.writeNumberField(fieldName, (Long) fieldValue); + gen.writeNumberProperty(fieldName, (Long) fieldValue); } else if (fieldValue instanceof Double) { - gen.writeNumberField(fieldName, (Double) fieldValue); + gen.writeNumberProperty(fieldName, (Double) fieldValue); } else if (fieldValue instanceof Float) { - gen.writeNumberField(fieldName, (Float) fieldValue); + gen.writeNumberProperty(fieldName, (Float) fieldValue); } else if (fieldValue instanceof BigDecimal) { - gen.writeNumberField(fieldName, (BigDecimal) fieldValue); + gen.writeNumberProperty(fieldName, (BigDecimal) fieldValue); } else if (fieldValue instanceof String) { - gen.writeStringField(fieldName, (String) fieldValue); + gen.writeStringProperty(fieldName, (String) fieldValue); } else if (fieldValue instanceof Date) { writeDateField(gen, fieldName, (Date) fieldValue); - + } else if (fieldValue instanceof TemporalAccessor) { + writeTemporalField(gen, fieldName, (TemporalAccessor) fieldValue); } else if (fieldValue instanceof Boolean) { - gen.writeBooleanField(fieldName, (Boolean) fieldValue); + gen.writeBooleanProperty(fieldName, (Boolean) fieldValue); } else if (DomainUtils.isEntity(fieldValue)) { writeEntity(gen, fieldName, fieldValue); } else if (fieldValue != null) { - gen.writeObjectField(fieldName, fieldValue); + gen.writePOJOProperty(fieldName, fieldValue); } } - private void writeEntity(JsonGenerator gen, String fieldName, Object entity) throws IOException { + private void writeEntity(JsonGenerator gen, String fieldName, Object entity) { Field field = viewDescriptor.getField(fieldName); if (field.getParams().get("include") == Boolean.TRUE) { - gen.writeFieldName(fieldName); + gen.writeName(fieldName); ViewDescriptor fieldDescritor = getFieldViewDescriptor(field.getFieldClass()); JsonViewDescriptorSerializer serializer = new JsonViewDescriptorSerializer(fieldDescritor); serializer.serialize(entity, gen, null); } else { - gen.writeObjectFieldStart(fieldName); + gen.writeStartObject(fieldName); Object id = DomainUtils.findEntityId(entity); if (id != null) { writeField(gen, "id", id); } - gen.writeStringField("name", entity.toString()); + gen.writeStringProperty("name", entity.toString()); if (entity instanceof URLable) { - gen.writeStringField("url", ((URLable) entity).toURL()); + gen.writeStringProperty("url", ((URLable) entity).toURL()); } gen.writeEndObject(); } } - private void writeDateField(JsonGenerator gen, String fieldName, Date date) throws IOException { + private void writeDateField(JsonGenerator gen, String fieldName, Date date) { Field field = viewDescriptor.getField(fieldName); String format = "basic"; @@ -211,7 +220,33 @@ private void writeDateField(JsonGenerator gen, String fieldName, Date date) thro }; - gen.writeStringField(fieldName, df.format(date)); + gen.writeStringProperty(fieldName, df.format(date)); + } + + private void writeTemporalField(JsonGenerator gen, String fieldName, TemporalAccessor value) { + + Field field = viewDescriptor.getField(fieldName); + String format = null; + if (field != null && field.getParams().containsKey("format")) { + format = (String) field.getParams().get("format"); + } + + String valueStr = null; + if (format != null && !format.isEmpty()) { + valueStr = DateTimeUtils.getFormatter(format).format(value); + } else { + valueStr = switch (value) { + case java.time.LocalDateTime localDateTime -> Formatters.formatDateTime(localDateTime); + case java.time.LocalDate localDate -> Formatters.formatDate(localDate); + case java.time.LocalTime localTime -> Formatters.formatTime(localTime); + case java.time.ZonedDateTime zonedDateTime -> Formatters.formatZonedDateTime(zonedDateTime); + case java.time.OffsetDateTime offsetDateTime -> Formatters.formatOffsetDateTime(offsetDateTime); + case java.time.Instant instant -> Formatters.formatInstant(instant); + default -> value.toString(); + }; + } + + gen.writeStringProperty(fieldName, valueStr); } static class FieldMetadata { From fd7f1d5c08960f24c14b5b43e518869184382ecd Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 07:38:21 -0500 Subject: [PATCH 02/15] Migrate from Jackson 2 to Jackson 3 by updating module dependencies in module-info.java --- platform/core/commons/src/main/java/module-info.java | 8 ++------ platform/core/domain/src/main/java/module-info.java | 3 ++- platform/core/viewers/src/main/java/module-info.java | 3 ++- platform/core/web/src/main/java/module-info.java | 5 +++-- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/platform/core/commons/src/main/java/module-info.java b/platform/core/commons/src/main/java/module-info.java index 332103a3..cd629976 100644 --- a/platform/core/commons/src/main/java/module-info.java +++ b/platform/core/commons/src/main/java/module-info.java @@ -4,18 +4,14 @@ exports tools.dynamia.commons.collect; exports tools.dynamia.commons.logger; exports tools.dynamia.commons.math; - - requires com.fasterxml.jackson.annotation; - requires com.fasterxml.jackson.databind; - requires com.fasterxml.jackson.dataformat.xml; - requires org.slf4j; requires java.desktop; requires java.sql; - requires com.fasterxml.jackson.datatype.jsr310; requires spring.beans; requires spring.core; requires org.jspecify; + requires tools.jackson.databind; + requires tools.jackson.dataformat.xml; } diff --git a/platform/core/domain/src/main/java/module-info.java b/platform/core/domain/src/main/java/module-info.java index e59fdb6b..d9184692 100644 --- a/platform/core/domain/src/main/java/module-info.java +++ b/platform/core/domain/src/main/java/module-info.java @@ -3,12 +3,13 @@ requires tools.dynamia.integration; requires jakarta.validation; - requires com.fasterxml.jackson.databind; + requires spring.context; requires java.sql; requires spring.jdbc; requires java.net.http; requires spring.tx; + requires tools.jackson.databind; exports tools.dynamia.domain; exports tools.dynamia.domain.query; exports tools.dynamia.domain.util; diff --git a/platform/core/viewers/src/main/java/module-info.java b/platform/core/viewers/src/main/java/module-info.java index 6747cb67..9c793c18 100644 --- a/platform/core/viewers/src/main/java/module-info.java +++ b/platform/core/viewers/src/main/java/module-info.java @@ -8,9 +8,10 @@ requires jakarta.validation; requires tools.dynamia.integration; requires tools.dynamia.io; - requires com.fasterxml.jackson.databind; requires spring.beans; requires org.yaml.snakeyaml; requires spring.expression; requires tools.dynamia.actions; + requires tools.jackson.core; + requires tools.jackson.databind; } diff --git a/platform/core/web/src/main/java/module-info.java b/platform/core/web/src/main/java/module-info.java index 60950d1c..df5f64a6 100644 --- a/platform/core/web/src/main/java/module-info.java +++ b/platform/core/web/src/main/java/module-info.java @@ -11,13 +11,14 @@ requires spring.context; requires spring.beans; requires spring.core; - requires com.fasterxml.jackson.core; - requires com.fasterxml.jackson.databind; + requires java.net.http; requires java.scripting; requires jakarta.validation; requires io.swagger.v3.oas.annotations; + requires com.fasterxml.jackson.annotation; + requires tools.jackson.databind; exports tools.dynamia.web.navigation; exports tools.dynamia.web.util; } From dbc4fc9ea7a17bcab10ce0fed7831409289f4f31 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 07:38:29 -0500 Subject: [PATCH 03/15] Add methods to format ZonedDateTime, OffsetDateTime, and Instant as ISO date-time strings --- .../tools/dynamia/commons/Formatters.java | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/platform/core/commons/src/main/java/tools/dynamia/commons/Formatters.java b/platform/core/commons/src/main/java/tools/dynamia/commons/Formatters.java index e4ddd368..6db2a57e 100644 --- a/platform/core/commons/src/main/java/tools/dynamia/commons/Formatters.java +++ b/platform/core/commons/src/main/java/tools/dynamia/commons/Formatters.java @@ -19,9 +19,7 @@ import java.text.DateFormat; import java.text.DecimalFormat; import java.text.NumberFormat; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; +import java.time.*; import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAccessor; import java.util.Date; @@ -312,4 +310,43 @@ public static String formatDate(Date date, String pattern, java.util.Locale loca */ private Formatters() { } + + /** + * Formats a {@link ZonedDateTime} object as an ISO zoned date-time string. + * + * @param zonedDateTime the zoned date-time to format + * @return the formatted ISO zoned date-time string + */ + public static String formatZonedDateTime(ZonedDateTime zonedDateTime) { + if (zonedDateTime == null) { + return ""; + } + return zonedDateTime.format(DateTimeFormatter.ISO_ZONED_DATE_TIME); + } + + /** + * Formats an {@link OffsetDateTime} object as an ISO offset date-time string. + * + * @param offsetDateTime the offset date-time to format + * @return the formatted ISO offset date-time string + */ + public static String formatOffsetDateTime(OffsetDateTime offsetDateTime) { + if (offsetDateTime == null) { + return ""; + } + return offsetDateTime.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + + /** + * Formats an {@link Instant} object as an ISO offset date-time string in the default time zone. + * + * @param instant the instant to format + * @return the formatted ISO offset date-time string + */ + public static String formatInstant(Instant instant) { + if (instant == null) { + return ""; + } + return instant.atZone(Messages.getDefaultTimeZone()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } } From f313d883bda15581d8a752d88a5ed7c29e3b02a0 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 07:38:43 -0500 Subject: [PATCH 04/15] Update dependencies to version 26.3.0 and spring-boot-starter-parent to 4.0.3 in pom.xml --- examples/demo-zk-books/pom.xml | 4 +-- extensions/dashboard/sources/pom.xml | 6 ++--- extensions/email-sms/sources/core/pom.xml | 6 ++--- extensions/email-sms/sources/pom.xml | 4 +-- extensions/email-sms/sources/ui/pom.xml | 6 ++--- extensions/entity-files/sources/core/pom.xml | 8 +++--- extensions/entity-files/sources/pom.xml | 2 +- extensions/entity-files/sources/s3/pom.xml | 4 +-- extensions/entity-files/sources/ui/pom.xml | 6 ++--- extensions/file-importer/sources/core/pom.xml | 4 +-- extensions/file-importer/sources/pom.xml | 2 +- extensions/file-importer/sources/ui/pom.xml | 6 ++--- extensions/finances/sources/api/pom.xml | 2 +- extensions/finances/sources/pom.xml | 2 +- extensions/pom.xml | 2 +- extensions/reports/sources/api/pom.xml | 2 +- extensions/reports/sources/core/pom.xml | 12 ++++----- extensions/reports/sources/pom.xml | 2 +- extensions/reports/sources/ui/pom.xml | 8 +++--- extensions/saas/sources/api/pom.xml | 4 +-- extensions/saas/sources/core/pom.xml | 10 +++---- extensions/saas/sources/jpa/pom.xml | 6 ++--- extensions/saas/sources/pom.xml | 2 +- extensions/saas/sources/remote/pom.xml | 9 +++---- extensions/saas/sources/ui/pom.xml | 8 +++--- extensions/security/sources/core/pom.xml | 14 +++++----- extensions/security/sources/pom.xml | 2 +- extensions/security/sources/ui/pom.xml | 8 +++--- platform/app/pom.xml | 26 +++++++++---------- platform/core/actions/pom.xml | 6 ++--- platform/core/commons/pom.xml | 20 +++++--------- platform/core/crud/pom.xml | 10 +++---- platform/core/domain-jpa/pom.xml | 4 +-- platform/core/domain/pom.xml | 2 +- platform/core/integration/pom.xml | 4 +-- platform/core/io/pom.xml | 2 +- platform/core/navigation/pom.xml | 8 +++--- platform/core/reports/pom.xml | 2 +- platform/core/templates/pom.xml | 6 ++--- platform/core/viewers/pom.xml | 12 ++++----- platform/core/web/pom.xml | 12 ++++----- platform/starters/zk-starter/pom.xml | 10 +++---- platform/ui/ui-shared/pom.xml | 8 +++--- platform/ui/zk/pom.xml | 18 ++++++------- pom.xml | 4 +-- themes/pom.xml | 2 +- themes/theme-dynamical/sources/pom.xml | 4 +-- 47 files changed, 151 insertions(+), 160 deletions(-) diff --git a/examples/demo-zk-books/pom.xml b/examples/demo-zk-books/pom.xml index 9aefaa43..2713f1a7 100644 --- a/examples/demo-zk-books/pom.xml +++ b/examples/demo-zk-books/pom.xml @@ -32,7 +32,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.2 + 4.0.3 @@ -47,7 +47,7 @@ ${maven.build.timestamp} yyyyMMdd - 26.1 + 26.3.0 diff --git a/extensions/dashboard/sources/pom.xml b/extensions/dashboard/sources/pom.xml index b6ec577b..36647dbe 100644 --- a/extensions/dashboard/sources/pom.xml +++ b/extensions/dashboard/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.2.3 + 26.3.0 ../../pom.xml @@ -38,12 +38,12 @@ tools.dynamia tools.dynamia.zk - 26.2.3 + 26.3.0 tools.dynamia.modules tools.dynamia.modules.saas.api - 26.2.3 + 26.3.0 diff --git a/extensions/email-sms/sources/core/pom.xml b/extensions/email-sms/sources/core/pom.xml index 3828e3d9..3d704821 100644 --- a/extensions/email-sms/sources/core/pom.xml +++ b/extensions/email-sms/sources/core/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules.email.parent tools.dynamia.modules - 26.2.3 + 26.3.0 tools.dynamia.modules.email @@ -50,12 +50,12 @@ tools.dynamia tools.dynamia.domain.jpa - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.templates - 26.2.3 + 26.3.0 org.springframework diff --git a/extensions/email-sms/sources/pom.xml b/extensions/email-sms/sources/pom.xml index 7e5dde56..9612c468 100644 --- a/extensions/email-sms/sources/pom.xml +++ b/extensions/email-sms/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.2.3 + 26.3.0 ../../pom.xml @@ -85,7 +85,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.jpa - 26.2.3 + 26.3.0 diff --git a/extensions/email-sms/sources/ui/pom.xml b/extensions/email-sms/sources/ui/pom.xml index d4d49619..a26f7324 100644 --- a/extensions/email-sms/sources/ui/pom.xml +++ b/extensions/email-sms/sources/ui/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules.email.parent tools.dynamia.modules - 26.2.3 + 26.3.0 DynamiaModules - Email UI @@ -34,12 +34,12 @@ tools.dynamia tools.dynamia.zk - 26.2.3 + 26.3.0 tools.dynamia.modules tools.dynamia.modules.email - 26.2.3 + 26.3.0 tools.dynamia.zk.addons diff --git a/extensions/entity-files/sources/core/pom.xml b/extensions/entity-files/sources/core/pom.xml index d0fb9ff1..61d39477 100644 --- a/extensions/entity-files/sources/core/pom.xml +++ b/extensions/entity-files/sources/core/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules.entityfiles.parent tools.dynamia.modules - 26.2.3 + 26.3.0 DynamiaModules - EntityFiles - Core tools.dynamia.modules.entityfiles @@ -54,20 +54,20 @@ tools.dynamia tools.dynamia.domain.jpa - 26.2.3 + 26.3.0 jar tools.dynamia tools.dynamia.io - 26.2.3 + 26.3.0 jar tools.dynamia tools.dynamia.web - 26.2.3 + 26.3.0 jar diff --git a/extensions/entity-files/sources/pom.xml b/extensions/entity-files/sources/pom.xml index 7c9fa4ec..572d50db 100644 --- a/extensions/entity-files/sources/pom.xml +++ b/extensions/entity-files/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.2.3 + 26.3.0 ../../pom.xml diff --git a/extensions/entity-files/sources/s3/pom.xml b/extensions/entity-files/sources/s3/pom.xml index b960a917..e8e2b71a 100644 --- a/extensions/entity-files/sources/s3/pom.xml +++ b/extensions/entity-files/sources/s3/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles.parent - 26.2.3 + 26.3.0 DynamiaModules - EntityFiles - S3 @@ -49,7 +49,7 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles - 26.2.3 + 26.3.0 software.amazon.awssdk diff --git a/extensions/entity-files/sources/ui/pom.xml b/extensions/entity-files/sources/ui/pom.xml index 7da517f8..560ea87b 100644 --- a/extensions/entity-files/sources/ui/pom.xml +++ b/extensions/entity-files/sources/ui/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules.entityfiles.parent tools.dynamia.modules - 26.2.3 + 26.3.0 DynamiaModules - EntityFiles UI tools.dynamia.modules.entityfiles.ui @@ -48,12 +48,12 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.zk - 26.2.3 + 26.3.0 jar diff --git a/extensions/file-importer/sources/core/pom.xml b/extensions/file-importer/sources/core/pom.xml index 7b385c8b..96f93743 100644 --- a/extensions/file-importer/sources/core/pom.xml +++ b/extensions/file-importer/sources/core/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules.importer.parent tools.dynamia.modules - 26.2.3 + 26.3.0 Dynamia Modules - Importer Core tools.dynamia.modules.importer @@ -56,7 +56,7 @@ tools.dynamia tools.dynamia.reports - 26.2.3 + 26.3.0 diff --git a/extensions/file-importer/sources/pom.xml b/extensions/file-importer/sources/pom.xml index 329918e7..a8c867da 100644 --- a/extensions/file-importer/sources/pom.xml +++ b/extensions/file-importer/sources/pom.xml @@ -26,7 +26,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.2.3 + 26.3.0 ../../pom.xml diff --git a/extensions/file-importer/sources/ui/pom.xml b/extensions/file-importer/sources/ui/pom.xml index 314e31ed..7c1c98af 100644 --- a/extensions/file-importer/sources/ui/pom.xml +++ b/extensions/file-importer/sources/ui/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules.importer.parent tools.dynamia.modules - 26.2.3 + 26.3.0 Dynamia Modules - Importer UI tools.dynamia.modules.importer.ui @@ -55,13 +55,13 @@ tools.dynamia tools.dynamia.zk - 26.2.3 + 26.3.0 tools.dynamia.modules tools.dynamia.modules.importer - 26.2.3 + 26.3.0 diff --git a/extensions/finances/sources/api/pom.xml b/extensions/finances/sources/api/pom.xml index 1a90e2ba..e94960ff 100644 --- a/extensions/finances/sources/api/pom.xml +++ b/extensions/finances/sources/api/pom.xml @@ -26,7 +26,7 @@ tools.dynamia.modules tools.dynamia.modules.finances.parent - 26.2.3 + 26.3.0 Dynamia Modules - Finances API diff --git a/extensions/finances/sources/pom.xml b/extensions/finances/sources/pom.xml index bcf7462e..bc152a65 100644 --- a/extensions/finances/sources/pom.xml +++ b/extensions/finances/sources/pom.xml @@ -26,7 +26,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.2.3 + 26.3.0 ../../pom.xml diff --git a/extensions/pom.xml b/extensions/pom.xml index a2ef54cc..a365a695 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -6,7 +6,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../pom.xml diff --git a/extensions/reports/sources/api/pom.xml b/extensions/reports/sources/api/pom.xml index 494123a4..c8017804 100644 --- a/extensions/reports/sources/api/pom.xml +++ b/extensions/reports/sources/api/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.reports.parent - 26.2.3 + 26.3.0 DynamiaModules - Reports API diff --git a/extensions/reports/sources/core/pom.xml b/extensions/reports/sources/core/pom.xml index 295be7f1..5475b0fd 100644 --- a/extensions/reports/sources/core/pom.xml +++ b/extensions/reports/sources/core/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.reports.parent - 26.2.3 + 26.3.0 DynamiaModules - Reports Core @@ -50,17 +50,17 @@ tools.dynamia.modules tools.dynamia.modules.reports.api - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.domain.jpa - 26.2.3 + 26.3.0 tools.dynamia.modules tools.dynamia.modules.saas.jpa - 26.2.3 + 26.3.0 org.springframework @@ -69,12 +69,12 @@ tools.dynamia tools.dynamia.reports - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.templates - 26.2.3 + 26.3.0 compile diff --git a/extensions/reports/sources/pom.xml b/extensions/reports/sources/pom.xml index f6f3a03f..51844ad8 100644 --- a/extensions/reports/sources/pom.xml +++ b/extensions/reports/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.2.3 + 26.3.0 ../../pom.xml diff --git a/extensions/reports/sources/ui/pom.xml b/extensions/reports/sources/ui/pom.xml index c32cf2b6..45edbb4f 100644 --- a/extensions/reports/sources/ui/pom.xml +++ b/extensions/reports/sources/ui/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.reports.parent - 26.2.3 + 26.3.0 DynamiaModules - Reports UI @@ -49,17 +49,17 @@ tools.dynamia.modules tools.dynamia.modules.reports.core - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.zk - 26.2.3 + 26.3.0 tools.dynamia.modules tools.dynamia.modules.dashboard - 26.2.3 + 26.3.0 io.swagger.core.v3 diff --git a/extensions/saas/sources/api/pom.xml b/extensions/saas/sources/api/pom.xml index 297d3cb1..3d0a45bc 100644 --- a/extensions/saas/sources/api/pom.xml +++ b/extensions/saas/sources/api/pom.xml @@ -26,7 +26,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.parent - 26.2.3 + 26.3.0 @@ -55,7 +55,7 @@ tools.dynamia tools.dynamia.actions - 26.2.3 + 26.3.0 org.springframework.boot diff --git a/extensions/saas/sources/core/pom.xml b/extensions/saas/sources/core/pom.xml index c63dcbba..9070bb94 100644 --- a/extensions/saas/sources/core/pom.xml +++ b/extensions/saas/sources/core/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.parent - 26.2.3 + 26.3.0 DynamiaModules - SaaS Core @@ -49,18 +49,18 @@ tools.dynamia.modules tools.dynamia.modules.saas.api - 26.2.3 + 26.3.0 tools.dynamia.modules tools.dynamia.modules.saas.jpa - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.integration - 26.2.3 + 26.3.0 @@ -86,7 +86,7 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles - 26.2.3 + 26.3.0 org.hibernate.orm diff --git a/extensions/saas/sources/jpa/pom.xml b/extensions/saas/sources/jpa/pom.xml index d351ddcd..6ccfa942 100644 --- a/extensions/saas/sources/jpa/pom.xml +++ b/extensions/saas/sources/jpa/pom.xml @@ -24,7 +24,7 @@ tools.dynamia.modules.saas.parent tools.dynamia.modules - 26.2.3 + 26.3.0 DynamiaModules - SaaS JPA @@ -35,12 +35,12 @@ tools.dynamia.modules tools.dynamia.modules.saas.api - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.domain.jpa - 26.2.3 + 26.3.0 diff --git a/extensions/saas/sources/pom.xml b/extensions/saas/sources/pom.xml index f2769934..b77467b9 100644 --- a/extensions/saas/sources/pom.xml +++ b/extensions/saas/sources/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.2.3 + 26.3.0 ../../pom.xml diff --git a/extensions/saas/sources/remote/pom.xml b/extensions/saas/sources/remote/pom.xml index 4e2137ba..32cfcf4d 100644 --- a/extensions/saas/sources/remote/pom.xml +++ b/extensions/saas/sources/remote/pom.xml @@ -25,7 +25,7 @@ tools.dynamia.modules.saas.parent tools.dynamia.modules - 26.2.3 + 26.3.0 @@ -38,7 +38,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.jpa - 26.2.3 + 26.3.0 @@ -67,10 +67,7 @@ org.springframework spring-webmvc - - com.fasterxml.jackson.core - jackson-databind - + diff --git a/extensions/saas/sources/ui/pom.xml b/extensions/saas/sources/ui/pom.xml index 35c6d9c7..359bab8e 100644 --- a/extensions/saas/sources/ui/pom.xml +++ b/extensions/saas/sources/ui/pom.xml @@ -22,7 +22,7 @@ tools.dynamia.modules tools.dynamia.modules.saas.parent - 26.2.3 + 26.3.0 DynamiaModules - SaaS UI tools.dynamia.modules.saas.ui @@ -54,12 +54,12 @@ tools.dynamia tools.dynamia.zk - 26.2.3 + 26.3.0 tools.dynamia.modules tools.dynamia.modules.saas - 26.2.3 + 26.3.0 @@ -70,7 +70,7 @@ tools.dynamia.modules tools.dynamia.modules.entityfiles.ui - 26.2.3 + 26.3.0 diff --git a/extensions/security/sources/core/pom.xml b/extensions/security/sources/core/pom.xml index 32c92425..1676ef93 100644 --- a/extensions/security/sources/core/pom.xml +++ b/extensions/security/sources/core/pom.xml @@ -17,7 +17,7 @@ tools.dynamia.modules tools.dynamia.modules.security.parent - 26.2.3 + 26.3.0 4.0.0 @@ -32,34 +32,34 @@ tools.dynamia.modules tools.dynamia.modules.saas.api - 26.2.3 + 26.3.0 tools.dynamia.modules tools.dynamia.modules.entityfiles - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.domain.jpa - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.domain - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.integration - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.web - 26.2.3 + 26.3.0 diff --git a/extensions/security/sources/pom.xml b/extensions/security/sources/pom.xml index 22bb23bf..a5443197 100644 --- a/extensions/security/sources/pom.xml +++ b/extensions/security/sources/pom.xml @@ -19,7 +19,7 @@ tools.dynamia.modules tools.dynamia.modules.parent - 26.2.3 + 26.3.0 ../../pom.xml diff --git a/extensions/security/sources/ui/pom.xml b/extensions/security/sources/ui/pom.xml index 3f25dfdd..3a267e05 100644 --- a/extensions/security/sources/ui/pom.xml +++ b/extensions/security/sources/ui/pom.xml @@ -17,7 +17,7 @@ tools.dynamia.modules tools.dynamia.modules.security.parent - 26.2.3 + 26.3.0 DynamiaModules - Security UI @@ -44,18 +44,18 @@ tools.dynamia.modules tools.dynamia.modules.security - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.zk - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.app - 26.2.3 + 26.3.0 diff --git a/platform/app/pom.xml b/platform/app/pom.xml index b3aae7e1..4e236780 100644 --- a/platform/app/pom.xml +++ b/platform/app/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../pom.xml @@ -74,58 +74,58 @@ tools.dynamia tools.dynamia.actions - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.commons - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.crud - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.domain - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.integration - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.io - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.navigation - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.reports - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.templates - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.viewers - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.web - 26.2.3 + 26.3.0 @@ -205,7 +205,7 @@ tools.dynamia tools.dynamia.domain.jpa - 26.2.3 + 26.3.0 test diff --git a/platform/core/actions/pom.xml b/platform/core/actions/pom.xml index 1804b018..0130f667 100644 --- a/platform/core/actions/pom.xml +++ b/platform/core/actions/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml @@ -65,12 +65,12 @@ tools.dynamia tools.dynamia.integration - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.commons - 26.2.3 + 26.3.0 diff --git a/platform/core/commons/pom.xml b/platform/core/commons/pom.xml index 8e20bb75..4e245efe 100644 --- a/platform/core/commons/pom.xml +++ b/platform/core/commons/pom.xml @@ -25,7 +25,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml DynamiaTools - Commons @@ -57,27 +57,21 @@ - com.fasterxml.jackson.core + tools.jackson.core jackson-core - com.fasterxml.jackson.core + tools.jackson.core jackson-databind - com.fasterxml.jackson.core - jackson-annotations - - - com.fasterxml.jackson.dataformat + tools.jackson.dataformat jackson-dataformat-xml - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 + com.fasterxml.jackson.core + jackson-annotations - org.springframework spring-beans @@ -101,7 +95,7 @@ ${java.version} ${target.version} - ${source.encoding} + ${source.encoding} true diff --git a/platform/core/crud/pom.xml b/platform/core/crud/pom.xml index 76779e05..7bcbccb1 100644 --- a/platform/core/crud/pom.xml +++ b/platform/core/crud/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml @@ -62,23 +62,23 @@ tools.dynamia tools.dynamia.actions - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.viewers - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.navigation - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.domain.jpa - 26.2.3 + 26.3.0 test diff --git a/platform/core/domain-jpa/pom.xml b/platform/core/domain-jpa/pom.xml index 7fe72dd3..6492eab5 100644 --- a/platform/core/domain-jpa/pom.xml +++ b/platform/core/domain-jpa/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml @@ -65,7 +65,7 @@ tools.dynamia tools.dynamia.domain - 26.2.3 + 26.3.0 diff --git a/platform/core/domain/pom.xml b/platform/core/domain/pom.xml index 185bfdaa..06613727 100644 --- a/platform/core/domain/pom.xml +++ b/platform/core/domain/pom.xml @@ -26,7 +26,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml DynamiaTools - Domain diff --git a/platform/core/integration/pom.xml b/platform/core/integration/pom.xml index 1f444233..cb19fc1b 100644 --- a/platform/core/integration/pom.xml +++ b/platform/core/integration/pom.xml @@ -27,7 +27,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml @@ -67,7 +67,7 @@ tools.dynamia tools.dynamia.commons - 26.2.3 + 26.3.0 provided diff --git a/platform/core/io/pom.xml b/platform/core/io/pom.xml index bc59531e..1d3afc85 100644 --- a/platform/core/io/pom.xml +++ b/platform/core/io/pom.xml @@ -28,7 +28,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml diff --git a/platform/core/navigation/pom.xml b/platform/core/navigation/pom.xml index f262e5c6..90b286bf 100644 --- a/platform/core/navigation/pom.xml +++ b/platform/core/navigation/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml @@ -63,17 +63,17 @@ tools.dynamia tools.dynamia.commons - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.integration - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.actions - 26.2.3 + 26.3.0 diff --git a/platform/core/reports/pom.xml b/platform/core/reports/pom.xml index 7011a446..7c0aa77b 100644 --- a/platform/core/reports/pom.xml +++ b/platform/core/reports/pom.xml @@ -26,7 +26,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml diff --git a/platform/core/templates/pom.xml b/platform/core/templates/pom.xml index d2e9346d..8155ea2a 100644 --- a/platform/core/templates/pom.xml +++ b/platform/core/templates/pom.xml @@ -23,7 +23,7 @@ tools.dynamia.parent tools.dynamia - 26.2.3 + 26.3.0 ../../../pom.xml @@ -64,12 +64,12 @@ tools.dynamia tools.dynamia.integration - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.commons - 26.2.3 + 26.3.0 diff --git a/platform/core/viewers/pom.xml b/platform/core/viewers/pom.xml index c417c67a..05a3690a 100644 --- a/platform/core/viewers/pom.xml +++ b/platform/core/viewers/pom.xml @@ -25,7 +25,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml @@ -67,27 +67,27 @@ tools.dynamia tools.dynamia.commons - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.integration - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.io - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.domain - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.actions - 26.2.3 + 26.3.0 org.yaml diff --git a/platform/core/web/pom.xml b/platform/core/web/pom.xml index 827741e3..acfaeb72 100644 --- a/platform/core/web/pom.xml +++ b/platform/core/web/pom.xml @@ -29,7 +29,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml @@ -88,27 +88,27 @@ tools.dynamia tools.dynamia.commons - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.integration - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.navigation - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.viewers - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.crud - 26.2.3 + 26.3.0 org.springframework diff --git a/platform/starters/zk-starter/pom.xml b/platform/starters/zk-starter/pom.xml index 35f85f3c..c874f28a 100644 --- a/platform/starters/zk-starter/pom.xml +++ b/platform/starters/zk-starter/pom.xml @@ -4,7 +4,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml @@ -28,22 +28,22 @@ tools.dynamia tools.dynamia.app - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.commons - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.zk - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.domain.jpa - 26.2.3 + 26.3.0 org.hibernate.validator diff --git a/platform/ui/ui-shared/pom.xml b/platform/ui/ui-shared/pom.xml index 75461ff7..2631112f 100644 --- a/platform/ui/ui-shared/pom.xml +++ b/platform/ui/ui-shared/pom.xml @@ -23,7 +23,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../../../pom.xml @@ -64,17 +64,17 @@ tools.dynamia tools.dynamia.integration - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.commons - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.io - 26.2.3 + 26.3.0 diff --git a/platform/ui/zk/pom.xml b/platform/ui/zk/pom.xml index fcade802..998e96a7 100644 --- a/platform/ui/zk/pom.xml +++ b/platform/ui/zk/pom.xml @@ -21,7 +21,7 @@ tools.dynamia.parent tools.dynamia - 26.2.3 + 26.3.0 ../../../pom.xml 4.0.0 @@ -99,31 +99,31 @@ tools.dynamia tools.dynamia.web - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.navigation - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.ui - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.domain - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.viewers - 26.2.3 + 26.3.0 org.yaml @@ -134,19 +134,19 @@ tools.dynamia tools.dynamia.crud - 26.2.3 + 26.3.0 tools.dynamia tools.dynamia.reports - 26.2.3 + 26.3.0 compile tools.dynamia tools.dynamia.templates - 26.2.3 + 26.3.0 compile diff --git a/pom.xml b/pom.xml index f8a0e5fd..b93dc3b5 100644 --- a/pom.xml +++ b/pom.xml @@ -24,7 +24,7 @@ 4.0.0 tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 pom Dynamia Soluciones IT SAS @@ -57,7 +57,7 @@ ${project.baseUri} - 4.0.2 + 4.0.3 2.2.38 1 diff --git a/themes/pom.xml b/themes/pom.xml index 5eddb11d..000ddadf 100644 --- a/themes/pom.xml +++ b/themes/pom.xml @@ -6,7 +6,7 @@ tools.dynamia tools.dynamia.parent - 26.2.3 + 26.3.0 ../pom.xml diff --git a/themes/theme-dynamical/sources/pom.xml b/themes/theme-dynamical/sources/pom.xml index c714dffc..44b512f1 100644 --- a/themes/theme-dynamical/sources/pom.xml +++ b/themes/theme-dynamical/sources/pom.xml @@ -24,7 +24,7 @@ tools.dynamia.themes tools.dynamia.themes.parent - 26.2.3 + 26.3.0 ../../pom.xml @@ -102,7 +102,7 @@ tools.dynamia tools.dynamia.zk - 26.2.3 + 26.3.0 provided From 4c693786c86eaa8166d1a63582c2586af482dd83 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 07:38:49 -0500 Subject: [PATCH 05/15] Migrate from Jackson 2 to Jackson 3 by updating imports and exception handling in service and controller classes --- .../services/impl/ReportsServiceImpl.java | 5 +-- .../dynamia/modules/security/domain/User.java | 5 +-- .../dynamia/commons/StringPojoParser.java | 32 +++++++++---------- .../navigation/RestNavigationController.java | 9 +++--- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/extensions/reports/sources/core/src/main/java/tools/dynamia/modules/reports/core/services/impl/ReportsServiceImpl.java b/extensions/reports/sources/core/src/main/java/tools/dynamia/modules/reports/core/services/impl/ReportsServiceImpl.java index 24ea60c7..0c67ffc7 100644 --- a/extensions/reports/sources/core/src/main/java/tools/dynamia/modules/reports/core/services/impl/ReportsServiceImpl.java +++ b/extensions/reports/sources/core/src/main/java/tools/dynamia/modules/reports/core/services/impl/ReportsServiceImpl.java @@ -1,7 +1,6 @@ package tools.dynamia.modules.reports.core.services.impl; -import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter; -import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; + import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import org.hibernate.Hibernate; @@ -22,6 +21,8 @@ import tools.dynamia.modules.reports.core.domain.ReportFilter; import tools.dynamia.modules.reports.core.domain.ReportGroup; import tools.dynamia.modules.reports.core.services.ReportsService; +import tools.jackson.databind.ser.std.SimpleBeanPropertyFilter; +import tools.jackson.databind.ser.std.SimpleFilterProvider; import java.io.File; import java.io.IOException; diff --git a/extensions/security/sources/core/src/main/java/tools/dynamia/modules/security/domain/User.java b/extensions/security/sources/core/src/main/java/tools/dynamia/modules/security/domain/User.java index 8fed4275..0038927f 100644 --- a/extensions/security/sources/core/src/main/java/tools/dynamia/modules/security/domain/User.java +++ b/extensions/security/sources/core/src/main/java/tools/dynamia/modules/security/domain/User.java @@ -16,8 +16,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; + import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -37,6 +36,8 @@ import tools.dynamia.modules.saas.api.AccountAware; import tools.dynamia.modules.security.services.JWTService; import tools.dynamia.modules.security.services.UserService; +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonSerialize; import java.time.Duration; import java.util.ArrayList; diff --git a/platform/core/commons/src/main/java/tools/dynamia/commons/StringPojoParser.java b/platform/core/commons/src/main/java/tools/dynamia/commons/StringPojoParser.java index da55d078..c4c0f5d6 100644 --- a/platform/core/commons/src/main/java/tools/dynamia/commons/StringPojoParser.java +++ b/platform/core/commons/src/main/java/tools/dynamia/commons/StringPojoParser.java @@ -17,13 +17,13 @@ package tools.dynamia.commons; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JavaType; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.dataformat.xml.XmlMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.dataformat.xml.XmlMapper; import java.io.IOException; import java.util.List; @@ -54,7 +54,7 @@ public static String convertMapToJson(Map map) { } var jsonMapper = createJsonMapper(); return jsonMapper.writeValueAsString(map); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new JsonParsingException(e); } } @@ -68,7 +68,6 @@ public static JsonMapper createJsonMapper() { return JsonMapper.builder() .enable(SerializationFeature.INDENT_OUTPUT) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .addModule(new JavaTimeModule()) .build(); } @@ -86,7 +85,7 @@ public static String convertPojoToJson(Object pojo) { } var jsonMapper = createJsonMapper(); return jsonMapper.writeValueAsString(pojo); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new JsonParsingException(e); } } @@ -142,7 +141,7 @@ public static T parseJsonToPojo(String json, Class pojoType) { var jsonMapper = createJsonMapper(); return jsonMapper.readerFor(pojoType).readValue(json); - } catch (IOException e) { + } catch (JacksonException e) { throw new JsonParsingException(e); } } @@ -177,14 +176,14 @@ public static String convertPojoToXml(Object pojo) { } var xmlMapper = createXmlMapper(); return xmlMapper.writeValueAsString(pojo); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new XmlParsingException(e); } } /** * Create a xml {@link XmlMapper} with enable IDENT_OUTPUT and disabled FAIL_ON_EMPTY_BEANS. Also add support - * to {@link JavaTimeModule} from JSR310 dependency + * * * @return xml mapper */ @@ -192,7 +191,6 @@ public static XmlMapper createXmlMapper() { return XmlMapper.builder() .enable(SerializationFeature.INDENT_OUTPUT) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .addModule(new JavaTimeModule()) .build(); } @@ -207,7 +205,7 @@ public static T parseXmlToPojo(String xml, Class pojoType) { } var xmlMap = createXmlMapper(); return xmlMap.readerFor(pojoType).readValue(xml); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new XmlParsingException(e); } } @@ -228,7 +226,7 @@ public static List parseJsonToList(String json, Class pojoType) { constructCollectionType(List.class, pojoType); return jsonMapper.readerFor(type).readValue(json); - } catch (IOException e) { + } catch (JacksonException e) { throw new JsonParsingException(e); } } @@ -245,7 +243,7 @@ public static String convertListToJson(List list) { } var jsonMapper = createJsonMapper(); return jsonMapper.writeValueAsString(list); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new JsonParsingException(e); } } diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java index 33209067..93f1f874 100644 --- a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java @@ -17,8 +17,7 @@ package tools.dynamia.web.navigation; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; + import jakarta.servlet.http.HttpServletRequest; import org.springframework.core.annotation.Order; import org.springframework.http.HttpHeaders; @@ -47,6 +46,8 @@ import tools.dynamia.viewers.JsonViewDescriptorDeserializer; import tools.dynamia.viewers.ViewDescriptor; import tools.dynamia.viewers.util.Viewers; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; import java.io.IOException; import java.util.List; @@ -291,7 +292,7 @@ private ResponseEntity update(String path, Long id, String jsonData, Htt ObjectOperations.invokeSetMethod(entity, field.getPropertyInfo(), fieldValue); } }); - } catch (IOException e) { + } catch (JacksonException e) { log("Error updating entity", e); } @@ -345,7 +346,7 @@ public static ResponseEntity getMetadata(HttpServletRequest request, Vie try { return new ResponseEntity<>(mapper.writeValueAsString(viewDescriptor), HttpStatus.OK); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { return new ResponseEntity<>("ERROR: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } } From 7fbb92fe6357d92275881e418d288fff24db9447 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 20:31:30 -0500 Subject: [PATCH 06/15] Add LegacyDateDeserializer for handling legacy date formats and update BasicEntityJsonDeserializer constructor --- .../commons/LegacyDateDeserializer.java | 48 +++++++++++++++++++ .../dynamia/commons/StringPojoParser.java | 5 +- .../util/BasicEntityJsonDeserializer.java | 2 +- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 platform/core/commons/src/main/java/tools/dynamia/commons/LegacyDateDeserializer.java diff --git a/platform/core/commons/src/main/java/tools/dynamia/commons/LegacyDateDeserializer.java b/platform/core/commons/src/main/java/tools/dynamia/commons/LegacyDateDeserializer.java new file mode 100644 index 00000000..8589ec15 --- /dev/null +++ b/platform/core/commons/src/main/java/tools/dynamia/commons/LegacyDateDeserializer.java @@ -0,0 +1,48 @@ +package tools.dynamia.commons; + + +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.deser.std.StdDeserializer; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Custom deserializer to handle legacy date formats in JSON. It attempts to parse the date string using two patterns: + * 1. "yyyy-MM-dd HH:mm:ss" - for date-time strings that include both date and time. + * 2. "yyyy-MM-dd" - for date-only strings. + * @author Mario A. Serrano Leones + */ +public class LegacyDateDeserializer extends StdDeserializer { + + public static final String DATE_PATTERN = "yyyy-MM-dd"; + public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + + public LegacyDateDeserializer() { + super(LocalDate.class); + } + + @Override + public LocalDate deserialize(JsonParser p, DeserializationContext ctxt) { + + String value = p.getString(); + + if (value == null || value.isBlank()) { + return null; + } + + + try { + return LocalDateTime.parse(value, DateTimeFormatter.ofPattern(DATE_TIME_PATTERN)).toLocalDate(); + } catch (Exception e) { + + } + + + return LocalDate.parse(value, DateTimeFormatter.ofPattern(DATE_PATTERN)); + + + } +} diff --git a/platform/core/commons/src/main/java/tools/dynamia/commons/StringPojoParser.java b/platform/core/commons/src/main/java/tools/dynamia/commons/StringPojoParser.java index c4c0f5d6..3f5f0e39 100644 --- a/platform/core/commons/src/main/java/tools/dynamia/commons/StringPojoParser.java +++ b/platform/core/commons/src/main/java/tools/dynamia/commons/StringPojoParser.java @@ -20,6 +20,7 @@ import tools.jackson.core.JacksonException; import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.JavaType; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -68,6 +69,7 @@ public static JsonMapper createJsonMapper() { return JsonMapper.builder() .enable(SerializationFeature.INDENT_OUTPUT) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .build(); } @@ -130,7 +132,6 @@ public static Map parseJsonToStringMap(String json) { * * @param json * @param pojoType - * * @return object of type or null if json is null or empty */ public static T parseJsonToPojo(String json, Class pojoType) { @@ -149,6 +150,7 @@ public static T parseJsonToPojo(String json, Class pojoType) { /** * Parse JSON map to java type (java bean) + * * @param map * @param pojoType * @return object of type or null if json is null or empty @@ -184,7 +186,6 @@ public static String convertPojoToXml(Object pojo) { /** * Create a xml {@link XmlMapper} with enable IDENT_OUTPUT and disabled FAIL_ON_EMPTY_BEANS. Also add support * - * * @return xml mapper */ public static XmlMapper createXmlMapper() { diff --git a/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonDeserializer.java b/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonDeserializer.java index b0c208a7..1734e366 100644 --- a/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonDeserializer.java +++ b/platform/core/domain/src/main/java/tools/dynamia/domain/util/BasicEntityJsonDeserializer.java @@ -13,7 +13,7 @@ public class BasicEntityJsonDeserializer extends StdDeserializer { public BasicEntityJsonDeserializer() { - this(null); + this(AbstractEntity.class); } public BasicEntityJsonDeserializer(Class vc) { From d296f71a41193708fff9a17dae281e1b455be229 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 20:49:05 -0500 Subject: [PATCH 07/15] Refactor InputPanel to use Field object for binding and customization --- .../java/tools/dynamia/zk/ui/InputPanel.java | 40 +++++++------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/platform/ui/zk/src/main/java/tools/dynamia/zk/ui/InputPanel.java b/platform/ui/zk/src/main/java/tools/dynamia/zk/ui/InputPanel.java index 2f13afc4..ac7e1d40 100644 --- a/platform/ui/zk/src/main/java/tools/dynamia/zk/ui/InputPanel.java +++ b/platform/ui/zk/src/main/java/tools/dynamia/zk/ui/InputPanel.java @@ -20,38 +20,32 @@ import org.zkoss.zk.ui.Component; import org.zkoss.zk.ui.HtmlBasedComponent; import org.zkoss.zk.ui.event.Events; -import org.zkoss.zul.Button; -import org.zkoss.zul.Decimalbox; -import org.zkoss.zul.Div; -import org.zkoss.zul.Doublebox; -import org.zkoss.zul.Label; -import org.zkoss.zul.Textbox; -import org.zkoss.zul.Vbox; -import org.zkoss.zul.Window; +import org.zkoss.zul.*; import tools.dynamia.commons.ObjectOperations; import tools.dynamia.commons.logger.Loggable; -import tools.dynamia.integration.Containers; -import tools.dynamia.viewers.ComponentCustomizer; import tools.dynamia.viewers.Field; -import tools.dynamia.viewers.FieldCustomizer; import tools.dynamia.viewers.util.ComponentCustomizerUtil; +import tools.dynamia.viewers.util.Viewers; import tools.dynamia.web.util.HttpUtils; import tools.dynamia.zk.util.ZKBindingUtil; import tools.dynamia.zk.util.ZKUtil; -import java.util.Collection; +import java.io.Serial; @SuppressWarnings("rawtypes") public class InputPanel extends Div implements Loggable { public static final String ON_INPUT = "onInput"; + @Serial private static final long serialVersionUID = 7388726856898185544L; + public static final String BINDING_ATTRIBUTE = "bindingAttribute"; private HtmlBasedComponent textbox; private Label label; private Button okButton; private Object value; private final Class inputClass; + private Field inputField; public InputPanel() { this(null, null, String.class); @@ -70,9 +64,10 @@ public InputPanel(String label, Object value, Class inputClass) { this.value = value; renderView(label); + String bindingAttribute = inputField != null && inputField.getParam(BINDING_ATTRIBUTE) != null ? inputField.getParam(BINDING_ATTRIBUTE).toString() : null; Binder binder = ZKBindingUtil.createBinder(); ZKBindingUtil.initBinder(binder, this, this); - ZKBindingUtil.bindComponent(binder, textbox, "inputPanel.value", null); + ZKBindingUtil.bindComponent(binder, textbox, bindingAttribute, "inputPanel.value", null); ZKBindingUtil.bindBean(this, "inputPanel", this); binder.loadComponent(this, false); addListeners(); @@ -127,29 +122,24 @@ private void renderView(String label) { private Component buildTextbox() { Class componClass = null; - Field field = new Field("field", inputClass); - Collection customizers = Containers.get().findObjects(FieldCustomizer.class); - if (customizers != null) { - for (FieldCustomizer fieldCustomizer : customizers) { - fieldCustomizer.customize("form", field); - } - } + inputField = new Field("field", inputClass); + Viewers.customizeField("form", inputField); - if (field.getComponentClass() != null) { - componClass = field.getComponentClass(); + if (inputField.getComponentClass() != null) { + componClass = inputField.getComponentClass(); } else { componClass = Textbox.class; } Component comp = (Component) ObjectOperations.newInstance(componClass); - if (field.getComponentCustomizer() != null) { + if (inputField.getComponentCustomizer() != null) { try { - ComponentCustomizerUtil.customizeComponent(field, comp, field.getComponentCustomizer()); + ComponentCustomizerUtil.customizeComponent(inputField, comp, inputField.getComponentCustomizer()); } catch (Exception e) { log("Cannot create component customizer", e); } } - ObjectOperations.setupBean(comp, field.getParams()); + ObjectOperations.setupBean(comp, inputField.getParams()); return comp; } From ac6c25c0791a9f928c7c32b6fa20efd764d51d27 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 20:49:14 -0500 Subject: [PATCH 08/15] Add FilterBookByBuyDateAction and FilterBookByPublishDateAction for date-based filtering --- .../actions/FilterBookByBuyDateAction.java | 31 +++++++++++++++++++ .../FilterBookByPublishDateAction.java | 27 ++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 examples/demo-zk-books/src/main/java/mybookstore/actions/FilterBookByBuyDateAction.java create mode 100644 examples/demo-zk-books/src/main/java/mybookstore/actions/FilterBookByPublishDateAction.java diff --git a/examples/demo-zk-books/src/main/java/mybookstore/actions/FilterBookByBuyDateAction.java b/examples/demo-zk-books/src/main/java/mybookstore/actions/FilterBookByBuyDateAction.java new file mode 100644 index 00000000..48ebd58b --- /dev/null +++ b/examples/demo-zk-books/src/main/java/mybookstore/actions/FilterBookByBuyDateAction.java @@ -0,0 +1,31 @@ +package mybookstore.actions; + +import mybookstore.domain.Book; +import tools.dynamia.actions.InstallAction; +import tools.dynamia.crud.AbstractCrudAction; +import tools.dynamia.crud.CrudActionEvent; +import tools.dynamia.ui.UIMessages; + +import java.time.LocalDate; + +@InstallAction +public class FilterBookByBuyDateAction extends AbstractCrudAction { + + public FilterBookByBuyDateAction() { + setName("Filter By Buy Date"); + setApplicableClass(Book.class); + setImage("calendar"); + setType("primary"); + setPosition(1); + } + + @Override + public void actionPerformed(CrudActionEvent evt) { + UIMessages.showInput("Select Buy Date", LocalDate.class, date -> { + if (date != null) { + evt.getController().setParemeter("buyDate", date); //set parameter with the selected date + evt.getController().doQuery(); //execute query with the new parameter + } + }); + } +} diff --git a/examples/demo-zk-books/src/main/java/mybookstore/actions/FilterBookByPublishDateAction.java b/examples/demo-zk-books/src/main/java/mybookstore/actions/FilterBookByPublishDateAction.java new file mode 100644 index 00000000..e8479271 --- /dev/null +++ b/examples/demo-zk-books/src/main/java/mybookstore/actions/FilterBookByPublishDateAction.java @@ -0,0 +1,27 @@ +package mybookstore.actions; + +import mybookstore.domain.Book; +import tools.dynamia.actions.InstallAction; +import tools.dynamia.commons.LocalDateRange; +import tools.dynamia.crud.AbstractCrudAction; +import tools.dynamia.crud.CrudActionEvent; +import tools.dynamia.domain.query.QueryConditions; +import tools.dynamia.zk.actions.LocalDateboxRangeActionRenderer; + +@InstallAction +public class FilterBookByPublishDateAction extends AbstractCrudAction { + + public FilterBookByPublishDateAction() { + setRenderer(new LocalDateboxRangeActionRenderer()); //use a renderer that provides a date range input + setApplicableClass(Book.class); + setAlwaysVisible(true); + } + + @Override + public void actionPerformed(CrudActionEvent evt) { + if(evt.getData() instanceof LocalDateRange dateRange && !dateRange.isNull()){ + evt.getController().setParemeter("publishDate", QueryConditions.between(dateRange)); //set parameter with a between condition + evt.getController().doQuery(); //execute query with the new parameter + } + } +} From d9370dd5b10800b2ae5ddfc2e400c77d06534296 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 21:58:33 -0500 Subject: [PATCH 09/15] Refactor JsonViewDescriptorDeserializer and JsonViewDescriptorSerializer for improved JSON processing and collection handling --- .../JsonViewDescriptorDeserializer.java | 154 +++++++------ .../viewers/JsonViewDescriptorSerializer.java | 214 +++++++++--------- 2 files changed, 198 insertions(+), 170 deletions(-) diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java index c02d81f3..9f3bb74c 100644 --- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java +++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java @@ -18,6 +18,7 @@ package tools.dynamia.viewers; +import com.fasterxml.jackson.annotation.JsonIgnore; import tools.dynamia.commons.ObjectOperations; import tools.dynamia.commons.logger.LoggingService; import tools.dynamia.commons.logger.SLF4JLoggingService; @@ -29,20 +30,37 @@ import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.JsonNode; import tools.jackson.databind.deser.std.StdDeserializer; -import tools.jackson.databind.util.StdDateFormat; import java.math.BigDecimal; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; public class JsonViewDescriptorDeserializer extends StdDeserializer { private final ViewDescriptor viewDescriptor; - private final StdDateFormat dateFormat = new StdDateFormat(); private static final LoggingService LOGGER = new SLF4JLoggingService(JsonViewDescriptorDeserializer.class); - public JsonViewDescriptorDeserializer(ViewDescriptor viewDescriptor) { - this(viewDescriptor, null); + private static final Map, Function> TYPE_EXTRACTORS = new HashMap<>(); + private static final Map, ViewDescriptor> DESCRIPTOR_CACHE = new ConcurrentHashMap<>(); + + static { + TYPE_EXTRACTORS.put(String.class, JsonNode::stringValue); + TYPE_EXTRACTORS.put(Long.class, JsonNode::longValue); + TYPE_EXTRACTORS.put(long.class, JsonNode::longValue); + TYPE_EXTRACTORS.put(Integer.class, JsonNode::intValue); + TYPE_EXTRACTORS.put(int.class, JsonNode::intValue); + TYPE_EXTRACTORS.put(Float.class, JsonNode::floatValue); + TYPE_EXTRACTORS.put(float.class, JsonNode::floatValue); + TYPE_EXTRACTORS.put(Double.class, JsonNode::doubleValue); + TYPE_EXTRACTORS.put(double.class, JsonNode::doubleValue); + TYPE_EXTRACTORS.put(BigDecimal.class, JsonNode::decimalValue); + TYPE_EXTRACTORS.put(Boolean.class, JsonNode::asBoolean); + TYPE_EXTRACTORS.put(boolean.class, JsonNode::asBoolean); + } + public JsonViewDescriptorDeserializer(ViewDescriptor viewDescriptor) { + this(viewDescriptor, Object.class); } public JsonViewDescriptorDeserializer(ViewDescriptor viewDescriptor, Class t) { @@ -50,88 +68,92 @@ public JsonViewDescriptorDeserializer(ViewDescriptor viewDescriptor, Class type, JsonNode node, ViewDescriptor descriptor) { + Object object = ObjectOperations.newInstance(type); + for (Field field : Viewers.getFields(descriptor)) { PropertyInfo fieldInfo = field.getPropertyInfo(); - String fieldName = field.getName(); - JsonNode fieldNode = node.get(fieldName); + if (fieldInfo.isAnnotationPresent(JsonIgnore.class)) { + continue; + } + + JsonNode fieldNode = node.get(field.getName()); if (fieldNode == null) { continue; } if (fieldInfo.isCollection()) { - Collection collection = (Collection) ObjectOperations.invokeGetMethod(object, fieldInfo); - if (collection == null) { - if (fieldInfo.getType() == List.class) { - collection = new ArrayList(); - } else if (fieldInfo.getType() == Set.class) { - collection = new HashSet(); - } else { - collection = (Collection) ObjectOperations.newInstance(fieldInfo.getType()); - } - ObjectOperations.setFieldValue(fieldInfo, object, collection); - } - - ViewDescriptor collectionDescriptor = Viewers.findViewDescriptor(fieldInfo.getGenericType(), "json-form"); - if (collectionDescriptor == null) { - collectionDescriptor = Viewers.getViewDescriptor(fieldInfo.getGenericType(), "form"); - } - String parentName = ObjectOperations.findParentPropertyName(type, fieldInfo.getGenericType()); - if (field.getParams().get("parentName") != null) { - parentName = field.getParams().get("parentName").toString(); - } - for (JsonNode child : fieldNode) { - Object item = parseNode(fieldInfo.getGenericType(), child, collectionDescriptor); - ObjectOperations.invokeSetMethod(item, parentName, object); - //noinspection unchecked - collection.add(item); - } - + processCollectionField(object, field, fieldInfo, fieldNode, type); } else { - Object fieldValue = getNodeValue(fieldInfo, fieldNode); - try { - ObjectOperations.invokeSetMethod(object, fieldName, fieldValue); - } catch (ReflectionException e) { - LOGGER.warn("Cannot parse json to field " + fieldName + " = " + fieldValue + ": " + e.getMessage()); - } - - + setSimpleField(object, field.getName(), fieldInfo, fieldNode); } } return object; } - public static Object getNodeValue(PropertyInfo fieldInfo, JsonNode fieldNode) { - Object fieldValue = null; + private void processCollectionField(Object object, Field field, PropertyInfo fieldInfo, + JsonNode fieldNode, Class parentType) { + Collection collection = getOrCreateCollection(object, fieldInfo); + ViewDescriptor collectionDescriptor = resolveCollectionDescriptor(fieldInfo.getGenericType()); + String parentName = resolveParentName(field, fieldInfo, parentType); + for (JsonNode child : fieldNode) { + Object item = parseNode(fieldInfo.getGenericType(), child, collectionDescriptor); + ObjectOperations.invokeSetMethod(item, parentName, object); + collection.add(item); + } + } + + @SuppressWarnings("unchecked") + private Collection getOrCreateCollection(Object object, PropertyInfo fieldInfo) { + Collection collection = (Collection) ObjectOperations.invokeGetMethod(object, fieldInfo); + if (collection == null) { + collection = createEmptyCollection(fieldInfo.getType()); + ObjectOperations.setFieldValue(fieldInfo, object, collection); + } + return collection; + } + + @SuppressWarnings("unchecked") + private Collection createEmptyCollection(Class type) { + if (type == List.class) return new ArrayList<>(); + if (type == Set.class) return new HashSet<>(); + return (Collection) ObjectOperations.newInstance(type); + } + + private String resolveParentName(Field field, PropertyInfo fieldInfo, Class parentType) { + Object customParent = field.getParams().get("parentName"); + return customParent != null + ? customParent.toString() + : ObjectOperations.findParentPropertyName(parentType, fieldInfo.getGenericType()); + } + + private void setSimpleField(Object object, String fieldName, PropertyInfo fieldInfo, JsonNode fieldNode) { + Object fieldValue = getNodeValue(fieldInfo, fieldNode); + try { + ObjectOperations.invokeSetMethod(object, fieldName, fieldValue); + } catch (ReflectionException e) { + LOGGER.warn("Cannot parse json to field " + fieldName + " = " + fieldValue + ": " + e.getMessage()); + } + } + + private ViewDescriptor resolveCollectionDescriptor(Class genericType) { + return DESCRIPTOR_CACHE.computeIfAbsent(genericType, type -> { + ViewDescriptor descriptor = Viewers.findViewDescriptor(type, "json-form"); + return descriptor != null ? descriptor : Viewers.getViewDescriptor(type, "form"); + }); + } + + public static Object getNodeValue(PropertyInfo fieldInfo, JsonNode fieldNode) { if (DomainUtils.isEntity(fieldInfo.getType()) && fieldNode.get("id") != null) { - long id = fieldNode.get("id").asLong(); - fieldValue = DomainUtils.lookupCrudService().find(fieldInfo.getType(), id); - } else if (fieldInfo.is(String.class)) { - fieldValue = fieldNode.textValue(); - } else if (fieldInfo.is(Long.class) || fieldInfo.is(long.class)) { - fieldValue = fieldNode.longValue(); - } else if (fieldInfo.is(Integer.class) || fieldInfo.is(int.class)) { - fieldValue = fieldNode.intValue(); - } else if (fieldInfo.is(Float.class) || fieldInfo.is(float.class)) { - fieldValue = fieldNode.floatValue(); - } else if (fieldInfo.is(Double.class) || fieldInfo.is(double.class)) { - fieldValue = fieldNode.doubleValue(); - } else if (fieldInfo.is(BigDecimal.class)) { - fieldValue = fieldNode.decimalValue(); - } else if (fieldInfo.is(Boolean.class)) { - fieldValue = fieldNode.booleanValue(); + return DomainUtils.lookupCrudService().find(fieldInfo.getType(), fieldNode.get("id").asLong()); } - return fieldValue; + Function extractor = TYPE_EXTRACTORS.get(fieldInfo.getType()); + return extractor != null ? extractor.apply(fieldNode) : null; } } diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorSerializer.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorSerializer.java index 6d5aeeef..b2d428f9 100644 --- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorSerializer.java +++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorSerializer.java @@ -18,6 +18,7 @@ package tools.dynamia.viewers; +import com.fasterxml.jackson.annotation.JsonIgnore; import tools.dynamia.commons.DateTimeUtils; import tools.dynamia.commons.Formatters; import tools.dynamia.commons.ObjectOperations; @@ -40,22 +41,24 @@ import java.math.BigDecimal; import java.text.DateFormat; import java.text.SimpleDateFormat; -import java.time.Instant; -import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAccessor; import java.util.Collection; import java.util.Date; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public class JsonViewDescriptorSerializer extends StdSerializer { - private final ViewDescriptor viewDescriptor; - private final StdDateFormat fullDateFormat = new StdDateFormat(); - private final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); - private final DateFormat timeFormat = new SimpleDateFormat("HH:mm:ss"); - private final DateFormat basicDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); + private static final LoggingService LOGGER = new SLF4JLoggingService(JsonViewDescriptorSerializer.class); + + private static final StdDateFormat FULL_DATE_FORMAT = new StdDateFormat(); + private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd"); + private static final DateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm:ss"); + private static final DateFormat BASIC_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); + private static final Map, ViewDescriptor> DESCRIPTOR_CACHE = new ConcurrentHashMap<>(); - private final static LoggingService LOGGER = new SLF4JLoggingService(JsonViewDescriptorSerializer.class); + private final ViewDescriptor viewDescriptor; public JsonViewDescriptorSerializer(ViewDescriptor viewDescriptor) { this(viewDescriptor, null); @@ -83,73 +86,111 @@ public void serialize(Object value, JsonGenerator gen, SerializationContext prov for (Field field : Viewers.getFields(viewDescriptor)) { PropertyInfo fieldInfo = field.getPropertyInfo(); if (field.isCollection() && fieldInfo != null) { - Collection collection = null; - try { - collection = (Collection) ObjectOperations.invokeGetMethod(value, field.getName()); - collection.isEmpty(); - } catch (Throwable e) { - if (field.isEntity()) { - String parentName = ObjectOperations.findParentPropertyName(viewDescriptor.getBeanClass(), fieldInfo.getGenericType()); - if (parentName != null) { - collection = DomainUtils.lookupCrudService().find(fieldInfo.getGenericType(), parentName, value); - } - } else { - collection = null; - LOGGER.warn("Cannot serialize collection " + field.getName() + "of class " + viewDescriptor.getBeanClass() + ": " + e.getMessage()); - } - } - - if (collection != null && !collection.isEmpty()) { - gen.writeStartArray(field.getName()); - ViewDescriptor collectionDescriptor = getFieldViewDescriptor(fieldInfo.getGenericType()); - JsonViewDescriptorSerializer collectionSerializer = new JsonViewDescriptorSerializer(collectionDescriptor); - for (Object item : collection) { - collectionSerializer.serialize(item, gen, provider); - } - gen.writeEndArray(); - } + serializeCollectionField(field, fieldInfo, value, gen, provider); } else { + serializeSimpleField(field, fieldInfo, value, gen); + } + } + + gen.writeEndObject(); + } - String fieldName = field.getName(); - try { - Object fieldValue = field.getPropertyInfo() != null && field.getPropertyInfo().is(boolean.class) ? ObjectOperations.invokeBooleanGetMethod(value, field.getName()) : ObjectOperations.invokeGetMethod(value, fieldName); - if (fieldInfo != null && fieldInfo.isAnnotationPresent(Reference.class)) { - Reference reference = fieldInfo.getAnnotation(Reference.class); - EntityReferenceRepository repository = DomainUtils.getEntityReferenceRepositoryByAlias(reference.value()); - @SuppressWarnings("unchecked") EntityReference entityReference = repository.load((Serializable) fieldValue); - if (entityReference != null) { - gen.writeStartObject(fieldName); - writeField(gen, "id", entityReference.getId()); - writeField(gen, "name", entityReference.getName()); - gen.writeEndObject(); - } else { - writeField(gen, fieldName, fieldValue); - } - } else { - writeField(gen, fieldName, fieldValue); - } - } catch (ReflectionException e) { - LOGGER.warn("Cannot write field " + fieldName + " to json: " + e.getMessage()); + private void serializeCollectionField(Field field, PropertyInfo fieldInfo, Object value, + JsonGenerator gen, SerializationContext provider) { + Collection collection = null; + try { + collection = (Collection) ObjectOperations.invokeGetMethod(value, field.getName()); + // trigger lazy-loading check: if it throws, we fall back below + int size = collection.size(); + if (size == 0) return; + } catch (Throwable e) { + if (field.isEntity()) { + String parentName = ObjectOperations.findParentPropertyName(viewDescriptor.getBeanClass(), fieldInfo.getGenericType()); + if (parentName != null) { + collection = DomainUtils.lookupCrudService().find(fieldInfo.getGenericType(), parentName, value); } + } else { + collection = null; + LOGGER.warn("Cannot serialize collection " + field.getName() + " of class " + viewDescriptor.getBeanClass() + ": " + e.getMessage()); + } + } + if (collection != null && !collection.isEmpty()) { + gen.writeArrayPropertyStart(field.getName()); + ViewDescriptor collectionDescriptor = getFieldViewDescriptor(fieldInfo.getGenericType()); + JsonViewDescriptorSerializer collectionSerializer = new JsonViewDescriptorSerializer(collectionDescriptor); + for (Object item : collection) { + collectionSerializer.serialize(item, gen, provider); } + gen.writeEndArray(); } + } - gen.writeEndObject(); + private void serializeSimpleField(Field field, PropertyInfo fieldInfo, Object value, JsonGenerator gen) { + if (!isSerializable(field, fieldInfo)) { + return; + } + String fieldName = field.getName(); + try { + Object fieldValue = fieldInfo != null && fieldInfo.is(boolean.class) + ? ObjectOperations.invokeBooleanGetMethod(value, fieldName) + : ObjectOperations.invokeGetMethod(value, fieldName); + + if (fieldInfo != null && fieldInfo.isAnnotationPresent(Reference.class)) { + serializeReferenceField(gen, fieldName, fieldValue, fieldInfo); + } else { + writeField(gen, fieldName, fieldValue); + } + } catch (ReflectionException e) { + LOGGER.warn("Cannot write field " + fieldName + " to json: " + e.getMessage()); + } } - private ViewDescriptor getFieldViewDescriptor(Class clazz) { - ViewDescriptor descriptor = Viewers.findViewDescriptor(clazz, "json"); + private boolean isSerializable(Field field, PropertyInfo fieldInfo) { + if (!field.isVisible()) { + return false; + } + + if (fieldInfo != null) { + if (fieldInfo.isAnnotationPresent(JsonIgnore.class)) { + return false; + } - if (descriptor == null) { - descriptor = Viewers.findViewDescriptor(clazz, "tree"); + return !fieldInfo.isTransient(); } - if (descriptor == null) { - descriptor = Viewers.getViewDescriptor(clazz, "table"); + + return true; + } + + private void serializeReferenceField(JsonGenerator gen, String fieldName, Object fieldValue, PropertyInfo fieldInfo) { + Reference reference = fieldInfo.getAnnotation(Reference.class); + @SuppressWarnings("unchecked") + EntityReferenceRepository repository = DomainUtils.getEntityReferenceRepositoryByAlias(reference.value()); + EntityReference entityReference = repository != null ? repository.load((Serializable) fieldValue) : null; + if (entityReference != null) { + gen.writeStartObject(fieldName); + writeField(gen, "id", entityReference.getId()); + writeField(gen, "name", entityReference.getName()); + gen.writeEndObject(); + } else { + writeField(gen, fieldName, fieldValue); } - return descriptor; + + } + + private ViewDescriptor getFieldViewDescriptor(Class clazz) { + return DESCRIPTOR_CACHE.computeIfAbsent(clazz, type -> { + ViewDescriptor descriptor = Viewers.findViewDescriptor(type, "json"); + if (descriptor == null) { + descriptor = Viewers.findViewDescriptor(type, "tree"); + } + if (descriptor == null) { + descriptor = Viewers.getViewDescriptor(type, "table"); + } + return descriptor; + }); } private void writeField(JsonGenerator gen, String fieldName, Object fieldValue) { @@ -187,7 +228,8 @@ private void writeEntity(JsonGenerator gen, String fieldName, Object entity) { JsonViewDescriptorSerializer serializer = new JsonViewDescriptorSerializer(fieldDescritor); serializer.serialize(entity, gen, null); } else { - gen.writeStartObject(fieldName); + + gen.writeObjectPropertyStart(fieldName); Object id = DomainUtils.findEntityId(entity); if (id != null) { writeField(gen, "id", id); @@ -213,10 +255,10 @@ private void writeDateField(JsonGenerator gen, String fieldName, Date date) { } DateFormat df = switch (format) { - case "ISO8601" -> fullDateFormat; - case "date" -> dateFormat; - case "time" -> timeFormat; - default -> basicDateFormat; + case "ISO8601" -> FULL_DATE_FORMAT; + case "date" -> DATE_FORMAT; + case "time" -> TIME_FORMAT; + default -> BASIC_DATE_FORMAT; }; @@ -231,7 +273,7 @@ private void writeTemporalField(JsonGenerator gen, String fieldName, TemporalAcc format = (String) field.getParams().get("format"); } - String valueStr = null; + String valueStr; if (format != null && !format.isEmpty()) { valueStr = DateTimeUtils.getFormatter(format).format(value); } else { @@ -249,42 +291,6 @@ private void writeTemporalField(JsonGenerator gen, String fieldName, TemporalAcc gen.writeStringProperty(fieldName, valueStr); } - static class FieldMetadata { - - private String label; - private String description; - private String type; - - public FieldMetadata(Field field) { - this.label = field.getLabel(); - this.description = field.getDescription(); - this.type = field.getFieldClass().getSimpleName(); - } - - - public String getLabel() { - return label; - } - - public void setLabel(String label) { - this.label = label; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } +} - public String getType() { - return type; - } - public void setType(String type) { - this.type = type; - } - } - -} From 9f459b06156eeab42fddde5317cb2daf3ddec4b7 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 22:34:40 -0500 Subject: [PATCH 10/15] Enhance JsonViewDescriptorDeserializer and JsonViewDescriptorSerializer to support nested dot-path fields for improved JSON serialization and deserialization --- .../JsonViewDescriptorDeserializer.java | 109 +++++++++++-- .../viewers/JsonViewDescriptorSerializer.java | 150 +++++++++++++++++- 2 files changed, 245 insertions(+), 14 deletions(-) diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java index 9f3bb74c..2a867a32 100644 --- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java +++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorDeserializer.java @@ -76,24 +76,115 @@ public Object deserialize(JsonParser jp, DeserializationContext ctxt) { private Object parseNode(Class type, JsonNode node, ViewDescriptor descriptor) { Object object = ObjectOperations.newInstance(type); + + // Group fields by their root prefix (part before the first dot). + // Fields without a dot are stored under their own name as key. + Map> groups = new LinkedHashMap<>(); for (Field field : Viewers.getFields(descriptor)) { + String name = field.getName(); + String root = name.contains(".") ? name.substring(0, name.indexOf('.')) : name; + groups.computeIfAbsent(root, k -> new ArrayList<>()).add(field); + } + + for (Map.Entry> entry : groups.entrySet()) { + List groupFields = entry.getValue(); + boolean isPathGroup = groupFields.size() > 1 || groupFields.getFirst().getName().contains("."); + + if (isPathGroup) { + // Navigate the JsonNode by root key and recursively set nested values + JsonNode groupNode = node.get(entry.getKey()); + if (groupNode != null) { + deserializePathGroup(object, groupFields, entry.getKey(), groupNode); + } + } else { + Field field = groupFields.getFirst(); + PropertyInfo fieldInfo = field.getPropertyInfo(); + if (fieldInfo == null || fieldInfo.isAnnotationPresent(JsonIgnore.class)) { + continue; + } + JsonNode fieldNode = node.get(field.getName()); + if (fieldNode == null) { + continue; + } + if (fieldInfo.isCollection()) { + processCollectionField(object, field, fieldInfo, fieldNode, type); + } else { + setSimpleField(object, field.getName(), fieldInfo, fieldNode); + } + } + } + return object; + } + + /** + * Recursively traverses a group of dot-path fields and sets their values on the target object. + *

+ * For example, given fields {@code category.id}, {@code category.name}, {@code category.type.name} + * and a {@code groupNode} that is the {@code "category"} JSON object, this method: + *

    + *
  • Sets {@code object.category.id} from {@code groupNode.id}
  • + *
  • Sets {@code object.category.name} from {@code groupNode.name}
  • + *
  • Recurses into {@code groupNode.type} for {@code category.type.name}
  • + *
+ * + * @param object the root bean being populated + * @param fields fields whose names start with the current prefix (full original dot-path names) + * @param prefix the dot-path prefix consumed so far (e.g. {@code "category"}) + * @param currentNode the JsonNode corresponding to {@code prefix} + */ + private void deserializePathGroup(Object object, List fields, String prefix, JsonNode currentNode) { + // Separate leaf fields from those that need another level of nesting. + // We strip the current prefix (e.g. "category") using its length to correctly + // handle out-of-order fields and deep paths regardless of dot position. + Map> subGroups = new LinkedHashMap<>(); + List leafFields = new ArrayList<>(); + + for (Field field : fields) { + String fullName = field.getName(); + // Strip the current prefix: "category.type.name" with prefix "category" → "type.name" + String remainder = fullName.length() > prefix.length() + 1 + ? fullName.substring(prefix.length() + 1) + : fullName; + if (remainder.contains(".")) { + // Still nested — group by the next segment (e.g. "type" from "type.name") + String nextRoot = remainder.substring(0, remainder.indexOf('.')); + subGroups.computeIfAbsent(nextRoot, k -> new ArrayList<>()).add(field); + } else { + // Leaf at this level (e.g. "id", "name") + leafFields.add(field); + } + } + + // Set leaf values using the full dot-path on the root object + for (Field field : leafFields) { + String fullName = field.getName(); // e.g. "category.id" + // The JSON key is the last segment of the full path + String subName = fullName.contains(".") ? fullName.substring(fullName.lastIndexOf('.') + 1) : fullName; PropertyInfo fieldInfo = field.getPropertyInfo(); - if (fieldInfo.isAnnotationPresent(JsonIgnore.class)) { + if (fieldInfo == null || fieldInfo.isAnnotationPresent(JsonIgnore.class)) { continue; } - - JsonNode fieldNode = node.get(field.getName()); - if (fieldNode == null) { + JsonNode leafNode = currentNode.get(subName); + if (leafNode == null) { continue; } + Object fieldValue = getNodeValue(fieldInfo, leafNode); + try { + // invokeSetMethod supports dot-notation and auto-creates null intermediate objects + ObjectOperations.invokeSetMethod(object, fullName, fieldValue); + } catch (ReflectionException e) { + LOGGER.warn("Cannot parse json path field " + fullName + " = " + fieldValue + ": " + e.getMessage()); + } + } - if (fieldInfo.isCollection()) { - processCollectionField(object, field, fieldInfo, fieldNode, type); - } else { - setSimpleField(object, field.getName(), fieldInfo, fieldNode); + // Recurse into sub-groups, passing the extended prefix and the matching sub-node + for (Map.Entry> sub : subGroups.entrySet()) { + String nextRoot = sub.getKey(); + JsonNode subNode = currentNode.get(nextRoot); + if (subNode != null) { + deserializePathGroup(object, sub.getValue(), prefix + "." + nextRoot, subNode); } } - return object; } private void processCollectionField(Object object, Field field, PropertyInfo fieldInfo, diff --git a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorSerializer.java b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorSerializer.java index b2d428f9..9d6280c4 100644 --- a/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorSerializer.java +++ b/platform/core/viewers/src/main/java/tools/dynamia/viewers/JsonViewDescriptorSerializer.java @@ -42,8 +42,11 @@ import java.text.DateFormat; import java.text.SimpleDateFormat; import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; import java.util.Collection; import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -81,20 +84,157 @@ public void serialize(Object value, JsonGenerator gen, SerializationContext prov if (id != null) { writeField(gen, "id", id); } - // writeField(gen, "name", value.toString()); + // Group fields by their root prefix (part before the first dot). + // Fields without a dot are stored under their own name as key. + Map> groups = new LinkedHashMap<>(); for (Field field : Viewers.getFields(viewDescriptor)) { - PropertyInfo fieldInfo = field.getPropertyInfo(); - if (field.isCollection() && fieldInfo != null) { - serializeCollectionField(field, fieldInfo, value, gen, provider); + String name = field.getName(); + String root = name.contains(".") ? name.substring(0, name.indexOf('.')) : name; + groups.computeIfAbsent(root, k -> new ArrayList<>()).add(field); + } + + for (Map.Entry> entry : groups.entrySet()) { + List groupFields = entry.getValue(); + boolean isPathGroup = groupFields.size() > 1 || groupFields.get(0).getName().contains("."); + + if (isPathGroup) { + // All fields in this group are path-based; render them as a nested object + serializePathGroup(entry.getKey(), groupFields, value, gen, provider); } else { - serializeSimpleField(field, fieldInfo, value, gen); + Field field = groupFields.get(0); + PropertyInfo fieldInfo = field.getPropertyInfo(); + if (field.isCollection() && fieldInfo != null) { + serializeCollectionField(field, fieldInfo, value, gen, provider); + } else { + serializeSimpleField(field, fieldInfo, value, gen); + } } } gen.writeEndObject(); } + /** + * Writes a group of dot-path fields as a nested JSON object, recursively. + * Supports arbitrarily deep paths, e.g.: + *
+     * category.id, category.name, category.type.name  →
+     * "category": { "id": 1, "name": "...", "type": { "name": "..." } }
+     * 
+ * + * @param rootName the JSON key to write (current nesting level) + * @param fields the fields whose names begin with rootName (full original paths) + * @param value the root bean being serialized + * @param pathSoFar the dot-path prefix already consumed (used to read values from the root bean) + * @param gen the JSON generator + */ + private void serializePathGroup(String rootName, List fields, Object value, + String pathSoFar, JsonGenerator gen) { + // Split fields into: leaf fields (no more dots after stripping rootName) + // and sub-groups (need another nesting level). + Map> subGroups = new LinkedHashMap<>(); + List leafFields = new ArrayList<>(); + + for (Field field : fields) { + if (!field.isVisible()) { + continue; + } + String fullName = field.getName(); + // Strip the current root prefix to get the remainder + String remainder = fullName.contains(".") ? fullName.substring(fullName.indexOf('.') + 1) : fullName; + if (remainder.contains(".")) { + // Still nested — group by the next segment + String nextRoot = remainder.substring(0, remainder.indexOf('.')); + subGroups.computeIfAbsent(nextRoot, k -> new ArrayList<>()).add(field); + } else { + leafFields.add(field); + } + } + + // Only open the object if there is something to write + boolean hasLeafValues = leafFields.stream().anyMatch(f -> { + try { + return ObjectOperations.invokeGetMethod(value, f.getName()) != null; + } catch (Exception e) { + return false; + } + }); + boolean hasSubGroups = !subGroups.isEmpty(); + + if (!hasLeafValues && !hasSubGroups) { + return; + } + + gen.writeObjectPropertyStart(rootName); + + // Write leaf fields + for (Field field : leafFields) { + String fullName = field.getName(); + String subName = fullName.contains(".") ? fullName.substring(fullName.lastIndexOf('.') + 1) : fullName; + try { + Object fieldValue = ObjectOperations.invokeGetMethod(value, fullName); + if (fieldValue != null) { + writeField(gen, subName, fieldValue); + } + } catch (Exception e) { + LOGGER.warn("Cannot write path field " + fullName + " to json: " + e.getMessage()); + } + } + + // Recurse into sub-groups + for (Map.Entry> sub : subGroups.entrySet()) { + // Re-root the field names so the next level sees them starting from sub.getKey() + String nextRoot = sub.getKey(); + List subFields = sub.getValue().stream() + .map(f -> { + // Trim one prefix level from the field name for the recursive call + String trimmed = f.getName().substring(f.getName().indexOf('.') + 1); + return new PathAliasField(f, trimmed); + }) + .collect(java.util.stream.Collectors.toList()); + serializePathGroup(nextRoot, subFields, value, pathSoFar, gen); + } + + gen.writeEndObject(); + } + + /** Overload used by the top-level serialize() method — starts pathSoFar as empty. */ + private void serializePathGroup(String rootName, List fields, Object value, + JsonGenerator gen, SerializationContext provider) { + serializePathGroup(rootName, fields, value, "", gen); + } + + /** + * A lightweight wrapper around a {@link Field} that overrides only {@link #getName()} + * so that recursive calls see the trimmed path rather than the full original path. + */ + private static class PathAliasField extends Field { + private final Field delegate; + private final String aliasName; + + PathAliasField(Field delegate, String aliasName) { + super(aliasName); + this.delegate = delegate; + this.aliasName = aliasName; + } + + @Override + public String getName() { + return aliasName; + } + + @Override + public boolean isVisible() { + return delegate.isVisible(); + } + + @Override + public java.util.Map getParams() { + return delegate.getParams(); + } + } + private void serializeCollectionField(Field field, PropertyInfo fieldInfo, Object value, JsonGenerator gen, SerializationContext provider) { Collection collection = null; From 8e18d126247a701ff337b90c92642012b789d2f4 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 22:34:45 -0500 Subject: [PATCH 11/15] Enable auto-growing of nested paths in PropertyAccessor for improved property handling --- .../main/java/tools/dynamia/commons/ops/PropertyAccessor.java | 1 + 1 file changed, 1 insertion(+) diff --git a/platform/core/commons/src/main/java/tools/dynamia/commons/ops/PropertyAccessor.java b/platform/core/commons/src/main/java/tools/dynamia/commons/ops/PropertyAccessor.java index 054c988f..1cfc2741 100644 --- a/platform/core/commons/src/main/java/tools/dynamia/commons/ops/PropertyAccessor.java +++ b/platform/core/commons/src/main/java/tools/dynamia/commons/ops/PropertyAccessor.java @@ -363,6 +363,7 @@ public static void invokeSetMethod(final Object bean, final String name, final O // Strategy 1: Try BeanWrapper first (fastest, handles most cases including nested properties) try { BeanWrapper wrapper = new BeanWrapperImpl(bean); + wrapper.setAutoGrowNestedPaths(true); // auto-create null intermediate objects (e.g. product.category.name) wrapper.setPropertyValue(name, actualValue); return; // Success! } catch (Exception e) { From e78dfe71275878c5dc7105a2e3af3773ae47adc8 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 22:34:54 -0500 Subject: [PATCH 12/15] Refactor RestNavigationController for improved code clarity and documentation --- .../navigation/RestNavigationController.java | 456 ++++++++++++++---- 1 file changed, 353 insertions(+), 103 deletions(-) diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java index 93f1f874..1b93d51d 100644 --- a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java @@ -17,7 +17,6 @@ package tools.dynamia.web.navigation; import com.fasterxml.jackson.annotation.JsonInclude; - import jakarta.servlet.http.HttpServletRequest; import org.springframework.core.annotation.Order; import org.springframework.http.HttpHeaders; @@ -49,12 +48,26 @@ import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; -import java.io.IOException; import java.util.List; import java.util.Map; /** - * Controller responsible for handling RESTful API requests related to CRUD operations on entities defined in the application. It provides endpoints for reading, creating, updating, and deleting entities based on the paths defined in the application's navigation structure. The controller utilizes the CrudService to perform database operations and returns JSON responses with the appropriate data and metadata for each request. It also handles pagination and query parameters for listing entities, as well as access restrictions based on the navigation configuration. + * REST controller that handles CRUD operations on entities exposed through the application's + * navigation structure. Each entity's path is derived from its corresponding {@link CrudPage} + * definition, and this controller maps HTTP requests to the appropriate persistence operations + * via {@link CrudService}. + * + *

Supported operations per entity path:

+ *
    + *
  • GET (list) — paginated collection with optional query conditions
  • + *
  • GET (single) — retrieve one entity by ID
  • + *
  • POST — create a new entity from a JSON payload
  • + *
  • PUT — update an existing entity by ID
  • + *
  • DELETE — remove an entity by ID
  • + *
+ * + *

All responses are serialized as JSON using the entity's registered {@link ViewDescriptor}. + * Access control is enforced via {@link NavigationRestrictions}.

* * @author Mario A. Serrano Leones */ @@ -62,62 +75,118 @@ @Order(1000) public class RestNavigationController extends AbstractLoggable { + /** Default number of items returned per page when no {@code size} parameter is provided. */ + private static final int DEFAULT_PAGINATION_SIZE = 50; - private final static int DEFAULT_PAGINATION_SIZE = 50; private static final String JSON_FORM = "json-form"; private static final String JSON = "json"; private static final String FORM = "form"; - private final ModuleContainer moduleContainer; private final CrudService crudService; + /** + * Constructs a new {@code RestNavigationController}. + * + * @param moduleContainer the container used to resolve navigation pages by path + * @param crudService the service used to perform CRUD operations on entities + */ public RestNavigationController(ModuleContainer moduleContainer, CrudService crudService) { this.moduleContainer = moduleContainer; this.crudService = crudService; } + // ------------------------------------------------------------------------- + // Path extraction + // ------------------------------------------------------------------------- + + /** + * Extracts the navigation path from the incoming request URI, stripping the + * leading {@code /api/} prefix when present. + * + * @param request the current HTTP request + * @return the normalized navigation path + */ private String getPath(HttpServletRequest request) { - var path = request.getRequestURI(); + String path = request.getRequestURI(); if (path.startsWith("/api/")) { path = path.replaceFirst("/api/", ""); } return path; } + // ------------------------------------------------------------------------- + // Route entry points (delegators) + // ------------------------------------------------------------------------- + /** + * Entry point for reading all entities at the resolved path. + * + * @param request the current HTTP request + * @return a JSON response containing the paginated entity list + */ public ResponseEntity routeReadAll(HttpServletRequest request) { - String path = getPath(request); - return readAll(path, request); + return readAll(getPath(request), request); } + /** + * Entry point for reading a single entity by its ID. + * + * @param id the entity identifier + * @param request the current HTTP request + * @return a JSON response containing the requested entity, or 404 if not found + */ public ResponseEntity routeReadOne(@PathVariable Long id, HttpServletRequest request) { return readOne(getPath(request).replace("/" + id, ""), id, request); } - + /** + * Entry point for creating a new entity from the supplied JSON body. + * + * @param jsonData the JSON representation of the entity to create + * @param request the current HTTP request + * @return a JSON response containing the persisted entity + */ public ResponseEntity routeCreate(@RequestBody String jsonData, HttpServletRequest request) { - String path = getPath(request); - return create(path, jsonData, request); - + return create(getPath(request), jsonData, request); } + /** + * Entry point for updating an existing entity identified by {@code id}. + * + * @param id the entity identifier + * @param jsonData the JSON patch data with updated field values + * @param request the current HTTP request + * @return a JSON response containing the updated entity, or 404 if not found + */ public ResponseEntity routeUpdate(@PathVariable Long id, @RequestBody String jsonData, HttpServletRequest request) { - String path = getPath(request).replace("/" + id, ""); - return update(path, id, jsonData, request); + return update(getPath(request).replace("/" + id, ""), id, jsonData, request); } + /** + * Entry point for deleting an entity identified by {@code id}. + * + * @param id the entity identifier + * @param request the current HTTP request + * @return a JSON response containing the deleted entity's data, or 404 if not found + */ public ResponseEntity routeDelete(@PathVariable Long id, HttpServletRequest request) { - String path = getPath(request).replace("/" + id, ""); - return delete(path, id, request); - + return delete(getPath(request).replace("/" + id, ""), id, request); } - - //INTERNAL OPERATIONS - + // ------------------------------------------------------------------------- + // Internal CRUD operations + // ------------------------------------------------------------------------- + + /** + * Reads all entities of the type associated with the given navigation {@code path}, + * applying optional query conditions and pagination. + * + * @param path the navigation path resolving to a {@link CrudPage} + * @param request the current HTTP request (used for pagination and metadata params) + * @return a paginated JSON response or a metadata response when {@code _metadata} is requested + */ private ResponseEntity readAll(String path, HttpServletRequest request) { - CrudPage page = findCrudPage(path); Class entityClass = page.getEntityClass(); @@ -128,8 +197,8 @@ private ResponseEntity readAll(String path, HttpServletRequest request) } QueryBuilder query = QueryBuilder.select().from(entityClass, "e"); - if (page.getAttribute("queryParameters") != null) { - QueryParameters pageParams = (QueryParameters) page.getAttribute("queryParameters"); + QueryParameters pageParams = (QueryParameters) page.getAttribute("queryParameters"); + if (pageParams != null) { query.where(pageParams); } parseConditions(query, readDescriptor); @@ -139,29 +208,31 @@ private ResponseEntity readAll(String path, HttpServletRequest request) if (pageSize == 0) { pageSize = DEFAULT_PAGINATION_SIZE; } - if (pageSize > 0) { - query.getQueryParameters().paginate(pageSize); - } - + query.getQueryParameters().paginate(pageSize); List content = crudService.executeQuery(query); - var paginator = query.getQueryParameters().getPaginator(); + DataPaginator paginator = query.getQueryParameters().getPaginator(); if (paginator != null) { paginator.setPage(currentPage); } - ListResult result = buildListResult(content, paginator, currentPage); - - - return buildJsonResponse(readDescriptor, result, "OK"); + return buildJsonResponse(readDescriptor, buildListResult(content, paginator, currentPage), "OK"); } + /** + * Builds a {@link ListResult} from a raw content list, handling paged data sources + * when the content is a {@link PagedList}. + * + * @param content the raw list returned by the query + * @param paginator the {@link DataPaginator} associated with the query, may be {@code null} + * @param currenPage the requested page number (1-based); ignored when {@code <= 0} + * @return a populated {@link ListResult} ready for serialization + */ public static ListResult buildListResult(List content, DataPaginator paginator, int currenPage) { ListResult result = new ListResult(); if (content instanceof PagedList pagedList && paginator != null) { if (currenPage > 0) { pagedList.getDataSource().setActivePage(currenPage); - } result.setData(pagedList.getDataSource().getPageData()); result.setPageable(paginator); @@ -172,9 +243,15 @@ public static ListResult buildListResult(List content, DataPaginator paginator, return result; } - + /** + * Reads a single entity by its {@code id} from the {@link CrudPage} resolved by {@code path}. + * + * @param path the navigation path resolving to a {@link CrudPage} + * @param id the entity identifier + * @param request the current HTTP request + * @return a JSON response with the entity, or HTTP 404 if not found + */ private ResponseEntity readOne(String path, Long id, HttpServletRequest request) { - CrudPage page = findCrudPage(path); Class entityClass = page.getEntityClass(); @@ -186,107 +263,159 @@ private ResponseEntity readOne(String path, Long id, HttpServletRequest @SuppressWarnings("unchecked") Object result = crudService.find(entityClass, id); if (result == null) { - return new ResponseEntity<>("Entity " + entityClass.getSimpleName() + " with id " + id + " not found\n", HttpStatus.NOT_FOUND); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("Entity " + entityClass.getSimpleName() + " with id " + id + " not found\n"); } return buildJsonResponse(readDescriptor, result, "OK"); } + /** + * Builds a JSON response for a single entity, automatically resolving its + * {@link ViewDescriptor} via {@link #getJsonFormDescriptor(Class)}. + * + * @param result the entity to serialize + * @return a {@code 200 OK} JSON response + */ public static ResponseEntity buildJsonResponse(Object result) { - var descriptor = getJsonFormDescriptor(result.getClass()); + ViewDescriptor descriptor = getJsonFormDescriptor(result.getClass()); return buildJsonResponse(descriptor, result, "ok"); } + /** + * Builds a JSON response wrapping a single entity result inside a {@link SimpleResult}. + * Falls back to generic JSON serialization when no {@link ViewDescriptor} is available. + * + * @param readDescriptor the view descriptor controlling field serialization; may be {@code null} + * @param result the entity to serialize + * @param message the status message included in the response body + * @return a {@code 200 OK} JSON response + */ public static ResponseEntity buildJsonResponse(ViewDescriptor readDescriptor, Object result, String message) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - + HttpHeaders headers = jsonHeaders(); SimpleResult resultWrapper = new SimpleResult(result, message); - if (readDescriptor != null) { return new ResponseEntity<>(new JsonView<>(resultWrapper, readDescriptor).renderJson(), headers, HttpStatus.OK); - } else { - return new ResponseEntity<>(StringPojoParser.convertPojoToJson(result), headers, HttpStatus.OK); } + return new ResponseEntity<>(StringPojoParser.convertPojoToJson(result), headers, HttpStatus.OK); } + /** + * Builds a JSON response for a paginated list result. + * Falls back to generic JSON serialization when no {@link ViewDescriptor} is available. + * + * @param readDescriptor the view descriptor controlling field serialization; may be {@code null} + * @param result the {@link ListResult} to serialize + * @param message the status message (currently unused in the body for list results) + * @return a {@code 200 OK} JSON response + */ public static ResponseEntity buildJsonResponse(ViewDescriptor readDescriptor, ListResult result, String message) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); + HttpHeaders headers = jsonHeaders(); if (readDescriptor != null) { return new ResponseEntity<>(new JsonView<>(result, readDescriptor).renderJson(), headers, HttpStatus.OK); - } else { - return new ResponseEntity<>(StringPojoParser.convertPojoToJson(result), headers, HttpStatus.OK); } + return new ResponseEntity<>(StringPojoParser.convertPojoToJson(result), headers, HttpStatus.OK); } + /** + * Resolves the JSON form {@link ViewDescriptor} for the given entity class, + * without auto-creating one when none is found. + * + * @param entityClass the entity class to look up + * @return the resolved {@link ViewDescriptor}, or {@code null} if not found + */ public static ViewDescriptor getJsonFormDescriptor(Class entityClass) { return getJsonFormDescriptor(entityClass, false); } + /** + * Resolves the JSON form {@link ViewDescriptor} for the given entity class. + * The lookup order is: {@code json-form} → {@code json} → {@code form}. + * When {@code autocreate} is {@code true} and no descriptor is found, a default + * {@code form} descriptor is generated. + * + * @param entityClass the entity class to look up + * @param autocreate {@code true} to auto-generate a form descriptor when none exists + * @return the resolved (or auto-generated) {@link ViewDescriptor}, or {@code null} + */ public static ViewDescriptor getJsonFormDescriptor(Class entityClass, boolean autocreate) { - ViewDescriptor readDescriptor = Viewers.findViewDescriptor(entityClass, JSON_FORM); - - if (readDescriptor == null) { - readDescriptor = Viewers.findViewDescriptor(entityClass, JSON); + ViewDescriptor descriptor = Viewers.findViewDescriptor(entityClass, JSON_FORM); + if (descriptor == null) { + descriptor = Viewers.findViewDescriptor(entityClass, JSON); } - - if (readDescriptor == null) { - readDescriptor = Viewers.findViewDescriptor(entityClass, FORM); + if (descriptor == null) { + descriptor = Viewers.findViewDescriptor(entityClass, FORM); } - - if (readDescriptor == null && autocreate) { - readDescriptor = Viewers.getViewDescriptor(entityClass, FORM); + if (descriptor == null && autocreate) { + descriptor = Viewers.getViewDescriptor(entityClass, FORM); } - return readDescriptor; + return descriptor; } + /** + * Resolves the JSON table {@link ViewDescriptor} for the given entity class. + * The lookup order is: {@code json} → {@code tree} → {@code table}. + * + * @param entityClass the entity class to look up + * @return the resolved {@link ViewDescriptor}, or {@code null} if none exists + */ public static ViewDescriptor getJsonTableDescriptor(Class entityClass) { - ViewDescriptor readDescriptor = Viewers.findViewDescriptor(entityClass, JSON); - if (readDescriptor == null) { - readDescriptor = Viewers.findViewDescriptor(entityClass, "tree"); + ViewDescriptor descriptor = Viewers.findViewDescriptor(entityClass, JSON); + if (descriptor == null) { + descriptor = Viewers.findViewDescriptor(entityClass, "tree"); } - - if (readDescriptor == null) { - readDescriptor = Viewers.findViewDescriptor(entityClass, "table"); + if (descriptor == null) { + descriptor = Viewers.findViewDescriptor(entityClass, "table"); } - return readDescriptor; + return descriptor; } - + /** + * Creates a new entity by parsing the supplied JSON payload and persisting it + * through {@link CrudService}. + * + * @param path the navigation path resolving to a {@link CrudPage} + * @param jsonData the JSON representation of the new entity + * @param request the current HTTP request (unused, reserved for future use) + * @return a JSON response containing the newly created entity + */ private ResponseEntity create(String path, String jsonData, HttpServletRequest request) { - CrudPage page = findCrudPage(path); Class entityClass = page.getEntityClass(); - ViewDescriptor descriptor = getJsonFormDescriptor(entityClass, true); JsonView jsonView = new JsonView(descriptor); jsonView.parse(jsonData); - Object newEntity = jsonView.getValue(); - newEntity = crudService.create(newEntity); + Object newEntity = crudService.create(jsonView.getValue()); return buildJsonResponse(descriptor, newEntity, "Created Successfully"); } + /** + * Updates an existing entity by applying the JSON patch fields to the persistent + * instance identified by {@code id}. + * + * @param path the navigation path resolving to a {@link CrudPage} + * @param id the entity identifier + * @param jsonData the JSON object containing the fields to update + * @param request the current HTTP request (unused, reserved for future use) + * @return a JSON response with the updated entity, or HTTP 404 if not found + */ private ResponseEntity update(String path, Long id, String jsonData, HttpServletRequest request) { - CrudPage page = findCrudPage(path); Class entityClass = page.getEntityClass(); @SuppressWarnings("unchecked") final Object entity = crudService.find(entityClass, id); if (entity == null) { - return new ResponseEntity<>("Entity " + entityClass.getSimpleName() + " with id " + id + " not found", HttpStatus.NOT_FOUND); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("Entity " + entityClass.getSimpleName() + " with id " + id + " not found"); } - ViewDescriptor descriptor = getJsonFormDescriptor(entityClass, true); - var mapper = StringPojoParser.createJsonMapper(); + ViewDescriptor descriptor = getJsonFormDescriptor(entityClass, true); try { - final ViewDescriptor desc = descriptor; - JsonNode node = mapper.readTree(jsonData); - System.out.println("TYPE: " + node.getNodeType()); + JsonNode node = StringPojoParser.createJsonMapper().readTree(jsonData); node.properties().forEach(entry -> { - Field field = desc.getField(entry.getKey()); + Field field = descriptor.getField(entry.getKey()); if (field != null) { Object fieldValue = JsonViewDescriptorDeserializer.getNodeValue(field.getPropertyInfo(), entry.getValue()); ObjectOperations.invokeSetMethod(entity, field.getPropertyInfo(), fieldValue); @@ -296,56 +425,87 @@ private ResponseEntity update(String path, Long id, String jsonData, Htt log("Error updating entity", e); } - - Object updatedEntity = crudService.update(entity); - return buildJsonResponse(descriptor, updatedEntity, "Updated Successfully"); + return buildJsonResponse(descriptor, crudService.update(entity), "Updated Successfully"); } + /** + * Deletes the entity identified by {@code id} and returns its last known state. + * + * @param path the navigation path resolving to a {@link CrudPage} + * @param id the entity identifier + * @param request the current HTTP request (unused, reserved for future use) + * @return a JSON response with the deleted entity's data, or HTTP 404 if not found + */ private ResponseEntity delete(String path, Long id, HttpServletRequest request) { - CrudPage page = findCrudPage(path); Class entityClass = page.getEntityClass(); Object result = crudService.find(entityClass, id); if (result == null) { - return new ResponseEntity<>("Entity " + entityClass.getSimpleName() + " with id " + id + " not found\n", HttpStatus.NOT_FOUND); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("Entity " + entityClass.getSimpleName() + " with id " + id + " not found\n"); } - crudService.delete(entityClass, id); - ViewDescriptor readDescriptor = getJsonFormDescriptor(entityClass); - return buildJsonResponse(readDescriptor, result, "Deleted Successfully"); + crudService.delete(entityClass, id); + return buildJsonResponse(getJsonFormDescriptor(entityClass), result, "Deleted Successfully"); } - + // ------------------------------------------------------------------------- + // Utility / helper methods + // ------------------------------------------------------------------------- + + /** + * Applies any JPQL {@code conditions} declared in the {@link ViewDescriptor}'s parameters + * to the given {@link QueryBuilder}. Each condition string is appended as an AND clause. + * + * @param query the query builder to augment + * @param descriptor the view descriptor that may contain a {@code conditions} parameter map + */ public static void parseConditions(QueryBuilder query, ViewDescriptor descriptor) { try { if (descriptor.getParams().containsKey("conditions")) { - @SuppressWarnings("unchecked") Map conditions = (Map) descriptor.getParams().get("conditions"); + @SuppressWarnings("unchecked") Map conditions = + (Map) descriptor.getParams().get("conditions"); conditions.forEach((k, v) -> query.and(v)); } - } catch (Exception ignored) { - LoggingService.get(RestNavigationController.class).error("Error parsing conditions", ignored); + } catch (Exception e) { + LoggingService.get(RestNavigationController.class).error("Error parsing conditions", e); } } + /** + * Reads an integer request parameter by name. Returns {@code 0} if the parameter + * is absent or cannot be parsed as an integer. + * + * @param request the current HTTP request + * @param name the name of the request parameter + * @return the parsed integer value, or {@code 0} if not present / invalid + */ public static int getParameterNumber(HttpServletRequest request, String name) { - int param = 0; - if (request.getParameter(name) != null) { + String value = request.getParameter(name); + if (value != null) { try { - param = Integer.parseInt(request.getParameter(name)); + return Integer.parseInt(value); } catch (NumberFormatException ignored) { - + // fall through and return 0 } } - return param; + return 0; } + /** + * Returns a JSON {@link ResponseEntity} containing the serialized {@link ViewDescriptor} + * when the request includes the {@code _metadata} query parameter. Returns {@code null} + * when the condition is not met, allowing normal processing to continue. + * + * @param request the current HTTP request + * @param viewDescriptor the descriptor to serialize; if {@code null} this method returns {@code null} + * @return a metadata response, or {@code null} if not a metadata request + */ public static ResponseEntity getMetadata(HttpServletRequest request, ViewDescriptor viewDescriptor) { if (viewDescriptor != null && request.getParameter("_metadata") != null) { - var mapper = StringPojoParser.createJsonMapper(); - try { - return new ResponseEntity<>(mapper.writeValueAsString(viewDescriptor), HttpStatus.OK); + return new ResponseEntity<>(StringPojoParser.createJsonMapper().writeValueAsString(viewDescriptor), HttpStatus.OK); } catch (JacksonException e) { return new ResponseEntity<>("ERROR: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } @@ -353,74 +513,164 @@ public static ResponseEntity getMetadata(HttpServletRequest request, Vie return null; } - + /** + * Resolves a {@link CrudPage} by navigation {@code path}, verifying access restrictions. + * First attempts an exact path match, then falls back to a pretty/virtual-path lookup. + * + * @param path the navigation path to resolve + * @return the matching {@link CrudPage} + * @throws PageNotFoundException if the path does not resolve to a {@link CrudPage} + * or the resolved page is of the wrong type + */ private CrudPage findCrudPage(String path) { - - Page page = null; + Page page; try { page = moduleContainer.findPage(path); } catch (PageNotFoundException e) { page = moduleContainer.findPageByPrettyVirtualPath(path); } - if (page instanceof CrudPage) { + if (page instanceof CrudPage crudPage) { NavigationRestrictions.verifyAccess(page); - return (CrudPage) page; + return crudPage; } throw new PageNotFoundException("Invalid Path " + path); } + /** + * Creates a pre-configured {@link HttpHeaders} instance with + * {@code Content-Type: application/json}. + * + * @return JSON HTTP headers + */ + private static HttpHeaders jsonHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + // ------------------------------------------------------------------------- + // Inner result types + // ------------------------------------------------------------------------- + + /** + * Simple wrapper that pairs a single entity payload with a status message. + */ static class SimpleResult { + private Object data; private String response; + /** + * Creates a new {@code SimpleResult}. + * + * @param content the entity data to include in the response + * @param response a human-readable status message + */ public SimpleResult(Object content, String response) { this.data = content; this.response = response; } + /** + * Returns the entity data payload. + * + * @return the data object + */ public Object getData() { return data; } + /** + * Sets the entity data payload. + * + * @param data the data object + */ public void setData(Object data) { this.data = data; } + /** + * Returns the status message. + * + * @return the response message + */ public String getResponse() { return response; } + /** + * Sets the status message. + * + * @param response the response message + */ public void setResponse(String response) { this.response = response; } } + /** + * Wraps a paginated list of entities together with optional pagination metadata. + */ static class ListResult { + private List data; + + /** Pagination info; omitted from JSON when {@code null}. */ @JsonInclude(JsonInclude.Include.NON_NULL) private DataPaginator pageable; + private String response; + /** + * Returns the status message. + * + * @return the response message + */ public String getResponse() { return response; } + /** + * Sets the status message. + * + * @param response the response message + */ public void setResponse(String response) { this.response = response; } + /** + * Returns the list of entity records for the current page. + * + * @return the data list + */ public List getData() { return data; } + /** + * Sets the list of entity records. + * + * @param data the data list + */ public void setData(List data) { this.data = data; } + /** + * Returns the pagination metadata, or {@code null} if the result is not paginated. + * + * @return the {@link DataPaginator}, or {@code null} + */ public DataPaginator getPageable() { return pageable; } + /** + * Sets the pagination metadata. + * + * @param pageable the {@link DataPaginator} to attach + */ public void setPageable(DataPaginator pageable) { this.pageable = pageable; } From ef765549d0bcf94d509fc8d01051b22bcc5dcc79 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 22:45:00 -0500 Subject: [PATCH 13/15] Add dynamic field filtering to RestNavigationController for enhanced query capabilities --- .../navigation/RestNavigationController.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java index 1b93d51d..4a53a082 100644 --- a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java @@ -33,6 +33,7 @@ import tools.dynamia.commons.logger.LoggingService; import tools.dynamia.crud.CrudPage; import tools.dynamia.domain.query.DataPaginator; +import tools.dynamia.domain.query.QueryConditions; import tools.dynamia.domain.query.QueryParameters; import tools.dynamia.domain.services.CrudService; import tools.dynamia.domain.util.QueryBuilder; @@ -50,6 +51,7 @@ import java.util.List; import java.util.Map; +import java.util.Set; /** * REST controller that handles CRUD operations on entities exposed through the application's @@ -202,6 +204,7 @@ private ResponseEntity readAll(String path, HttpServletRequest request) query.where(pageParams); } parseConditions(query, readDescriptor); + applyRequestFilters(request, query, readDescriptor); int pageSize = getParameterNumber(request, "size"); int currentPage = getParameterNumber(request, "page"); @@ -473,6 +476,118 @@ public static void parseConditions(QueryBuilder query, ViewDescriptor descriptor } } + /** + * Applies dynamic field filters derived from HTTP query parameters to the given {@link QueryBuilder}. + * + *

Any request parameter whose name does not start with {@code _} and is not a reserved + * pagination keyword ({@code page}, {@code size}) is treated as a potential field filter. + * The filter value is matched against the entity field registered in the {@link ViewDescriptor} + * and the appropriate {@link QueryConditions} condition is selected based on the field's Java type:

+ * + *
    + *
  • {@link String} — {@code LIKE} with auto-searchable wildcard wrapping
  • + *
  • {@link Number} / numeric primitives — exact equality ({@code =})
  • + *
  • {@link Boolean} / {@code boolean} — exact equality ({@code =})
  • + *
  • {@link Enum} subtypes — exact equality ({@code =}) using {@link Enum#valueOf}
  • + *
  • Any other type — skipped; not safe to cast without type information
  • + *
+ * + *

Parameters for fields not present in the descriptor are silently ignored.

+ * + *

Usage example:

+ *
{@code GET /api/users?name=john&status=ACTIVE&age=30 }
+ * + * @param request the current HTTP request carrying the filter parameters + * @param query the query builder to augment with filter conditions + * @param descriptor the view descriptor used to resolve field metadata; if {@code null} this + * method does nothing + */ + public static void applyRequestFilters(HttpServletRequest request, QueryBuilder query, ViewDescriptor descriptor) { + if (descriptor == null) { + return; + } + + // Parameter names that are reserved for pagination / internal use and must not be treated as filters. + Set RESERVED_PARAMS = Set.of("page", "size"); + + request.getParameterMap().forEach((paramName, values) -> { + // Skip internal (_) params and pagination keywords + if (paramName.startsWith("_") || RESERVED_PARAMS.contains(paramName) || values.length == 0) { + return; + } + + Field field = descriptor.getField(paramName); + if (field == null) { + return; // Unknown field — ignore silently + } + + Class fieldType = field.getFieldClass(); + if (fieldType == null && field.getPropertyInfo() != null) { + fieldType = field.getPropertyInfo().getType(); + } + if (fieldType == null) { + return; // Cannot determine type — skip + } + + String rawValue = values[0]; + if (rawValue == null || rawValue.isBlank()) { + return; + } + + try { + if (fieldType == String.class) { + // LIKE with auto-searchable wildcard (e.g., "john" → "%john%") + query.and(paramName, QueryConditions.like(rawValue)); + + } else if (Number.class.isAssignableFrom(fieldType) || fieldType.isPrimitive() && fieldType != boolean.class) { + // Numeric equality — parse to the right numeric type + Object numericValue = parseNumber(rawValue, fieldType); + if (numericValue != null) { + query.and(paramName, QueryConditions.eq(numericValue)); + } + + } else if (fieldType == Boolean.class || fieldType == boolean.class) { + boolean boolValue = "true".equalsIgnoreCase(rawValue) || "1".equals(rawValue); + query.and(paramName, QueryConditions.eq(boolValue)); + + } else if (fieldType.isEnum()) { + @SuppressWarnings({"unchecked", "rawtypes"}) + Object enumValue = Enum.valueOf((Class) fieldType, rawValue.toUpperCase()); + query.and(paramName, QueryConditions.eq(enumValue)); + + } + // Date, entity references, collections, etc. are intentionally skipped: + // they require more complex handling and are out of scope for simple URL filtering. + } catch (Exception e) { + LoggingService.get(RestNavigationController.class) + .warn("Ignoring filter param '" + paramName + "=" + rawValue + "': " + e.getMessage()); + } + }); + } + + /** + * Parses a raw string value into the target numeric type. + * + *

Supports {@link Integer}, {@code int}, {@link Long}, {@code long}, + * {@link Double}, {@code double}, {@link Float}, {@code float}, + * {@link Short}, {@code short}, {@link Byte}, {@code byte}, + * and {@link java.math.BigDecimal}.

+ * + * @param raw the raw string to parse + * @param targetType the target numeric {@link Class} + * @return the parsed number, or {@code null} if the type is not supported + */ + private static Object parseNumber(String raw, Class targetType) { + if (targetType == Integer.class || targetType == int.class) return Integer.parseInt(raw); + if (targetType == Long.class || targetType == long.class) return Long.parseLong(raw); + if (targetType == Double.class || targetType == double.class) return Double.parseDouble(raw); + if (targetType == Float.class || targetType == float.class) return Float.parseFloat(raw); + if (targetType == Short.class || targetType == short.class) return Short.parseShort(raw); + if (targetType == Byte.class || targetType == byte.class) return Byte.parseByte(raw); + if (targetType == java.math.BigDecimal.class) return new java.math.BigDecimal(raw); + return null; + } + /** * Reads an integer request parameter by name. Returns {@code 0} if the parameter * is absent or cannot be parsed as an integer. From c8e4e737ae213e5def876ef342f48da229bdfc42 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 22:56:35 -0500 Subject: [PATCH 14/15] Implement REST operations for CRUD functionality in navigation API --- .../dynamia/web/navigation/ErrorResult.java | 177 +++++ .../navigation/RestApiExceptionHandler.java | 202 +++++ .../web/navigation/RestNavigationContext.java | 372 ++++++++++ .../navigation/RestNavigationController.java | 690 ++---------------- .../RestNavigationCreateOperation.java | 72 ++ .../RestNavigationDeleteOperation.java | 68 ++ .../RestNavigationQuerySupport.java | 279 +++++++ .../RestNavigationReadOperation.java | 180 +++++ .../RestNavigationUpdateOperation.java | 101 +++ 9 files changed, 1513 insertions(+), 628 deletions(-) create mode 100644 platform/core/web/src/main/java/tools/dynamia/web/navigation/ErrorResult.java create mode 100644 platform/core/web/src/main/java/tools/dynamia/web/navigation/RestApiExceptionHandler.java create mode 100644 platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationContext.java create mode 100644 platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationCreateOperation.java create mode 100644 platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationDeleteOperation.java create mode 100644 platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationQuerySupport.java create mode 100644 platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationReadOperation.java create mode 100644 platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationUpdateOperation.java diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/ErrorResult.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/ErrorResult.java new file mode 100644 index 00000000..2be85cb8 --- /dev/null +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/ErrorResult.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2026 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.web.navigation; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Standardized JSON error response body returned by the REST API for all error conditions. + * + *

Every field in this record is included in the serialized JSON response so that API clients + * receive a predictable, self-describing error envelope regardless of the failure type. + * The {@code details} map is omitted when empty to keep simple error responses concise.

+ * + *

JSON example — validation error

+ *
{@code
+ * {
+ *   "timestamp": "2026-03-08T14:32:01.123Z",
+ *   "status": 422,
+ *   "error": "VALIDATION_ERROR",
+ *   "message": "Name is required",
+ *   "path": "/api/users",
+ *   "details": {
+ *     "invalidProperty": "name",
+ *     "invalidValue": "null"
+ *   }
+ * }
+ * }
+ * + *

JSON example — not found

+ *
{@code
+ * {
+ *   "timestamp": "2026-03-08T14:32:05.456Z",
+ *   "status": 404,
+ *   "error": "NOT_FOUND",
+ *   "message": "Invalid Path users/unknown",
+ *   "path": "/api/users/unknown"
+ * }
+ * }
+ * + *

Error codes

+ * + * + * + * + * + * + * + *
CodeHTTP StatusMeaning
{@code NOT_FOUND}404Navigation path not registered
{@code ACCESS_DENIED}403User lacks access to the requested page
{@code VALIDATION_ERROR}422Entity failed business / bean-validation rules
{@code BAD_REQUEST}400Invalid request parameter or body
{@code INTERNAL_ERROR}500Unexpected server-side failure
+ * + * @author Mario A. Serrano Leones + * @see RestApiExceptionHandler + */ +public class ErrorResult { + + /** ISO-8601 timestamp of when the error occurred, set automatically on construction. */ + private final String timestamp; + + /** HTTP status code (e.g. {@code 404}, {@code 422}). */ + private final int status; + + /** Machine-readable error code (e.g. {@code "NOT_FOUND"}, {@code "VALIDATION_ERROR"}). */ + private final String error; + + /** Human-readable description of the error, safe to display to the end user. */ + private final String message; + + /** The request URI that triggered the error. */ + private final String path; + + /** + * Optional map of additional error details (e.g. the invalid field name and value for + * validation errors). Omitted from JSON serialization when empty. + */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final Map details = new LinkedHashMap<>(); + + /** + * Constructs a new {@code ErrorResult}. + * + * @param status the HTTP status code + * @param error the machine-readable error code + * @param message the human-readable error message + * @param path the request URI that triggered the error + */ + public ErrorResult(int status, String error, String message, String path) { + this.timestamp = Instant.now().toString(); + this.status = status; + this.error = error; + this.message = message; + this.path = path; + } + + /** + * Adds an entry to the {@code details} map. + * + * @param key the detail key (e.g. {@code "invalidProperty"}) + * @param value the detail value + * @return this instance for method chaining + */ + public ErrorResult addDetail(String key, String value) { + details.put(key, value); + return this; + } + + /** + * Returns the ISO-8601 timestamp of when the error was created. + * + * @return the timestamp string + */ + public String getTimestamp() { + return timestamp; + } + + /** + * Returns the HTTP status code. + * + * @return the status code + */ + public int getStatus() { + return status; + } + + /** + * Returns the machine-readable error code. + * + * @return the error code + */ + public String getError() { + return error; + } + + /** + * Returns the human-readable error message. + * + * @return the message + */ + public String getMessage() { + return message; + } + + /** + * Returns the request URI that triggered the error. + * + * @return the request path + */ + public String getPath() { + return path; + } + + /** + * Returns the optional details map. May be empty but is never {@code null}. + * + * @return the details map + */ + public Map getDetails() { + return details; + } +} + diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestApiExceptionHandler.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestApiExceptionHandler.java new file mode 100644 index 00000000..050c0cf6 --- /dev/null +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestApiExceptionHandler.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2026 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.web.navigation; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import tools.dynamia.commons.logger.LoggingService; +import tools.dynamia.commons.logger.SLF4JLoggingService; +import tools.dynamia.domain.ValidationError; +import tools.dynamia.navigation.NavigationNotAllowedException; +import tools.dynamia.navigation.PageNotFoundException; + +/** + * Centralized exception handler for all REST API endpoints under {@code /api/**}. + * + *

Intercepts exceptions thrown by {@link RestNavigationController} and translates them + * into consistent, machine-readable JSON error responses using the {@link ErrorResult} structure. + * This keeps controller methods free of repetitive try/catch blocks and guarantees a uniform + * error contract for API clients.

+ * + *

Handled exceptions and HTTP status mapping

+ * + * + * + * + * + * + * + *
ExceptionHTTP StatusReason
{@link PageNotFoundException}404 Not FoundThe requested path does not map to any registered {@link tools.dynamia.crud.CrudPage}
{@link NavigationNotAllowedException}403 ForbiddenThe current user lacks access to the requested page
{@link ValidationError}422 Unprocessable EntityThe submitted entity failed business / bean-validation rules
{@link IllegalArgumentException}400 Bad RequestA request parameter or body could not be parsed or is semantically invalid
{@link Exception} (catch-all)500 Internal Server ErrorAny unexpected server-side failure
+ * + *

All responses carry {@code Content-Type: application/json} and a body that conforms to + * {@link ErrorResult}.

+ * + * @author Mario A. Serrano Leones + * @see ErrorResult + * @see RestNavigationController + */ +@RestControllerAdvice(basePackages = "tools.dynamia.web.navigation") +public class RestApiExceptionHandler { + + private static final LoggingService logger = new SLF4JLoggingService(RestApiExceptionHandler.class); + + // ------------------------------------------------------------------------- + // 404 — resource / path not found + // ------------------------------------------------------------------------- + + /** + * Handles {@link PageNotFoundException}, which is thrown when the request URI does not + * resolve to any registered {@link tools.dynamia.crud.CrudPage} in the navigation structure. + * + * @param ex the exception carrying the "not found" message + * @param request the current HTTP request + * @return a {@code 404 Not Found} JSON response + */ + @ExceptionHandler(PageNotFoundException.class) + public ResponseEntity handlePageNotFound(PageNotFoundException ex, HttpServletRequest request) { + logger.warn("Page not found: " + request.getRequestURI() + " - " + ex.getMessage()); + return errorResponse(HttpStatus.NOT_FOUND, "NOT_FOUND", ex.getMessage(), request); + } + + // ------------------------------------------------------------------------- + // 403 — access denied + // ------------------------------------------------------------------------- + + /** + * Handles {@link NavigationNotAllowedException}, thrown when + * {@link tools.dynamia.navigation.NavigationRestrictions#verifyAccess} denies the caller + * access to the requested page. + * + * @param ex the access-denied exception + * @param request the current HTTP request + * @return a {@code 403 Forbidden} JSON response + */ + @ExceptionHandler(NavigationNotAllowedException.class) + public ResponseEntity handleAccessDenied(NavigationNotAllowedException ex, HttpServletRequest request) { + logger.warn("Access denied to: " + request.getRequestURI() + " - " + ex.getMessage()); + return errorResponse(HttpStatus.FORBIDDEN, "ACCESS_DENIED", ex.getMessage(), request); + } + + // ------------------------------------------------------------------------- + // 422 — validation / business-rule failure + // ------------------------------------------------------------------------- + + /** + * Handles {@link ValidationError}, thrown by domain validators or bean-validation constraints + * when the submitted entity does not satisfy business rules. + * + *

The response body includes the {@code invalidProperty} and {@code invalidValue} fields + * from the {@link ValidationError} when they are available.

+ * + * @param ex the validation exception + * @param request the current HTTP request + * @return a {@code 422 Unprocessable Entity} JSON response + */ + @ExceptionHandler(ValidationError.class) + public ResponseEntity handleValidationError(ValidationError ex, HttpServletRequest request) { + logger.warn("Validation error on: " + request.getRequestURI() + " - " + ex.getMessage()); + ErrorResult error = new ErrorResult( + 422, + "VALIDATION_ERROR", + ex.getMessage(), + request.getRequestURI() + ); + if (ex.getInvalidProperty() != null) { + error.addDetail("invalidProperty", ex.getInvalidProperty()); + } + if (ex.getInvalidValue() != null) { + error.addDetail("invalidValue", String.valueOf(ex.getInvalidValue())); + } + return ResponseEntity + .status(HttpStatus.valueOf(422)) + .contentType(MediaType.APPLICATION_JSON) + .body(error); + } + + // ------------------------------------------------------------------------- + // 400 — bad request / illegal argument + // ------------------------------------------------------------------------- + + /** + * Handles {@link IllegalArgumentException}, raised when a request parameter or body + * value cannot be parsed or is semantically invalid (e.g., a non-numeric value for a + * numeric field). + * + * @param ex the illegal-argument exception + * @param request the current HTTP request + * @return a {@code 400 Bad Request} JSON response + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) { + logger.warn("Bad request on: " + request.getRequestURI() + " - " + ex.getMessage()); + return errorResponse(HttpStatus.BAD_REQUEST, "BAD_REQUEST", ex.getMessage(), request); + } + + // ------------------------------------------------------------------------- + // 500 — catch-all + // ------------------------------------------------------------------------- + + /** + * Catch-all handler for any unexpected exception not covered by the more specific handlers above. + * The full stack trace is logged at ERROR level while only a generic message is returned to + * the client to avoid leaking internal implementation details. + * + * @param ex the unexpected exception + * @param request the current HTTP request + * @return a {@code 500 Internal Server Error} JSON response + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex, HttpServletRequest request) { + logger.error("Unexpected error on: " + request.getRequestURI(), ex); + return errorResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + "INTERNAL_ERROR", + "An unexpected error occurred. Please contact the system administrator.", + request + ); + } + + // ------------------------------------------------------------------------- + // Helper + // ------------------------------------------------------------------------- + + /** + * Builds a {@link ResponseEntity} carrying an {@link ErrorResult} with + * {@code Content-Type: application/json}. + * + * @param status the HTTP status to use + * @param code the machine-readable error code + * @param message the human-readable error message + * @param request the current HTTP request (used to populate the {@code path} field) + * @return the fully constructed error response + */ + private static ResponseEntity errorResponse(HttpStatus status, String code, + String message, HttpServletRequest request) { + ErrorResult error = new ErrorResult(status.value(), code, message, request.getRequestURI()); + return ResponseEntity + .status(status) + .contentType(MediaType.APPLICATION_JSON) + .body(error); + } +} + + + diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationContext.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationContext.java new file mode 100644 index 00000000..4f6ff773 --- /dev/null +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationContext.java @@ -0,0 +1,372 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.web.navigation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import tools.dynamia.commons.StringPojoParser; +import tools.dynamia.crud.CrudPage; +import tools.dynamia.domain.query.DataPaginator; +import tools.dynamia.domain.services.CrudService; +import tools.dynamia.navigation.ModuleContainer; +import tools.dynamia.navigation.NavigationRestrictions; +import tools.dynamia.navigation.Page; +import tools.dynamia.navigation.PageNotFoundException; +import tools.dynamia.viewers.JsonView; +import tools.dynamia.viewers.ViewDescriptor; +import tools.dynamia.viewers.util.Viewers; +import tools.jackson.core.JacksonException; + +import java.util.List; + +/** + * Shared context for all REST navigation operation classes. + * + *

Holds the two required infrastructure dependencies ({@link ModuleContainer} and + * {@link CrudService}) and exposes the common utility operations used by every CRUD + * operation class:

+ *
    + *
  • Navigation path extraction from the raw request URI
  • + *
  • {@link CrudPage} resolution with access-restriction enforcement
  • + *
  • {@link ViewDescriptor} lookup (form / table variants)
  • + *
  • JSON {@link ResponseEntity} construction helpers
  • + *
  • {@code _metadata} short-circuit response
  • + *
+ * + *

The inner DTOs ({@link SimpleResult} and {@link ListResult}) live here so they are + * accessible to every operation in the same package without being part of the public API + * of the controller itself.

+ * + * @author Mario A. Serrano Leones + */ +public class RestNavigationContext { + + static final String JSON_FORM = "json-form"; + static final String JSON = "json"; + static final String FORM = "form"; + + private final ModuleContainer moduleContainer; + private final CrudService crudService; + + /** + * Constructs a new {@code RestNavigationContext}. + * + * @param moduleContainer the container used to resolve navigation pages by path + * @param crudService the service used to perform CRUD persistence operations + */ + public RestNavigationContext(ModuleContainer moduleContainer, CrudService crudService) { + this.moduleContainer = moduleContainer; + this.crudService = crudService; + } + + // ------------------------------------------------------------------------- + // Accessors + // ------------------------------------------------------------------------- + + /** + * Returns the {@link ModuleContainer} used to look up navigation pages. + * + * @return the module container + */ + public ModuleContainer getModuleContainer() { + return moduleContainer; + } + + /** + * Returns the {@link CrudService} used to execute persistence operations. + * + * @return the crud service + */ + public CrudService getCrudService() { + return crudService; + } + + // ------------------------------------------------------------------------- + // Path extraction + // ------------------------------------------------------------------------- + + /** + * Extracts the navigation path from the incoming request URI, stripping the + * leading {@code /api/} prefix when present. + * + * @param request the current HTTP request + * @return the normalized navigation path + */ + public String getPath(HttpServletRequest request) { + String path = request.getRequestURI(); + if (path.startsWith("/api/")) { + path = path.replaceFirst("/api/", ""); + } + return path; + } + + // ------------------------------------------------------------------------- + // Page resolution + // ------------------------------------------------------------------------- + + /** + * Resolves a {@link CrudPage} by navigation {@code path}, verifying access restrictions. + * First attempts an exact path match, then falls back to a pretty/virtual-path lookup. + * + * @param path the navigation path to resolve + * @return the matching {@link CrudPage} + * @throws PageNotFoundException if the path does not resolve to a {@link CrudPage} + * or the resolved page is of the wrong type + */ + public CrudPage findCrudPage(String path) { + Page page; + try { + page = moduleContainer.findPage(path); + } catch (PageNotFoundException e) { + page = moduleContainer.findPageByPrettyVirtualPath(path); + } + if (page instanceof CrudPage crudPage) { + NavigationRestrictions.verifyAccess(page); + return crudPage; + } + throw new PageNotFoundException("Invalid Path " + path); + } + + // ------------------------------------------------------------------------- + // ViewDescriptor lookup + // ------------------------------------------------------------------------- + + /** + * Resolves the JSON form {@link ViewDescriptor} for the given entity class, + * without auto-creating one when none is found. + * + *

Lookup order: {@code json-form} → {@code json} → {@code form}.

+ * + * @param entityClass the entity class to look up + * @return the resolved {@link ViewDescriptor}, or {@code null} if not found + */ + public static ViewDescriptor getJsonFormDescriptor(Class entityClass) { + return getJsonFormDescriptor(entityClass, false); + } + + /** + * Resolves the JSON form {@link ViewDescriptor} for the given entity class. + * + *

Lookup order: {@code json-form} → {@code json} → {@code form}. + * When {@code autocreate} is {@code true} and no descriptor is found, a default + * {@code form} descriptor is generated.

+ * + * @param entityClass the entity class to look up + * @param autocreate {@code true} to auto-generate a form descriptor when none exists + * @return the resolved (or auto-generated) {@link ViewDescriptor}, or {@code null} + */ + public static ViewDescriptor getJsonFormDescriptor(Class entityClass, boolean autocreate) { + ViewDescriptor descriptor = Viewers.findViewDescriptor(entityClass, JSON_FORM); + if (descriptor == null) { + descriptor = Viewers.findViewDescriptor(entityClass, JSON); + } + if (descriptor == null) { + descriptor = Viewers.findViewDescriptor(entityClass, FORM); + } + if (descriptor == null && autocreate) { + descriptor = Viewers.getViewDescriptor(entityClass, FORM); + } + return descriptor; + } + + /** + * Resolves the JSON table {@link ViewDescriptor} for the given entity class. + * + *

Lookup order: {@code json} → {@code tree} → {@code table}.

+ * + * @param entityClass the entity class to look up + * @return the resolved {@link ViewDescriptor}, or {@code null} if none exists + */ + public static ViewDescriptor getJsonTableDescriptor(Class entityClass) { + ViewDescriptor descriptor = Viewers.findViewDescriptor(entityClass, JSON); + if (descriptor == null) { + descriptor = Viewers.findViewDescriptor(entityClass, "tree"); + } + if (descriptor == null) { + descriptor = Viewers.findViewDescriptor(entityClass, "table"); + } + return descriptor; + } + + // ------------------------------------------------------------------------- + // JSON response builders + // ------------------------------------------------------------------------- + + /** + * Builds a JSON response for a single entity, automatically resolving its + * {@link ViewDescriptor} via {@link #getJsonFormDescriptor(Class)}. + * + * @param result the entity to serialize + * @return a {@code 200 OK} JSON response + */ + public static ResponseEntity buildJsonResponse(Object result) { + ViewDescriptor descriptor = getJsonFormDescriptor(result.getClass()); + return buildJsonResponse(descriptor, result, "ok"); + } + + /** + * Builds a JSON response wrapping a single entity inside a {@link SimpleResult}. + * Falls back to generic JSON serialization when no {@link ViewDescriptor} is available. + * + * @param readDescriptor the view descriptor controlling field serialization; may be {@code null} + * @param result the entity to serialize + * @param message the status message included in the response body + * @return a {@code 200 OK} JSON response + */ + public static ResponseEntity buildJsonResponse(ViewDescriptor readDescriptor, Object result, String message) { + HttpHeaders headers = jsonHeaders(); + SimpleResult resultWrapper = new SimpleResult(result, message); + if (readDescriptor != null) { + return new ResponseEntity<>(new JsonView<>(resultWrapper, readDescriptor).renderJson(), headers, HttpStatus.OK); + } + return new ResponseEntity<>(StringPojoParser.convertPojoToJson(result), headers, HttpStatus.OK); + } + + /** + * Builds a JSON response for a paginated list result. + * Falls back to generic JSON serialization when no {@link ViewDescriptor} is available. + * + * @param readDescriptor the view descriptor controlling field serialization; may be {@code null} + * @param result the {@link ListResult} to serialize + * @param message the status message (reserved for future use) + * @return a {@code 200 OK} JSON response + */ + public static ResponseEntity buildJsonResponse(ViewDescriptor readDescriptor, ListResult result, String message) { + HttpHeaders headers = jsonHeaders(); + if (readDescriptor != null) { + return new ResponseEntity<>(new JsonView<>(result, readDescriptor).renderJson(), headers, HttpStatus.OK); + } + return new ResponseEntity<>(StringPojoParser.convertPojoToJson(result), headers, HttpStatus.OK); + } + + // ------------------------------------------------------------------------- + // Metadata helper + // ------------------------------------------------------------------------- + + /** + * Returns a JSON {@link ResponseEntity} containing the serialized {@link ViewDescriptor} + * when the request includes the {@code _metadata} query parameter. Returns {@code null} + * when the condition is not met, allowing normal processing to continue. + * + * @param request the current HTTP request + * @param viewDescriptor the descriptor to serialize; if {@code null} this method returns {@code null} + * @return a metadata response, or {@code null} if not a metadata request + */ + public static ResponseEntity getMetadata(HttpServletRequest request, ViewDescriptor viewDescriptor) { + if (viewDescriptor != null && request.getParameter("_metadata") != null) { + try { + return new ResponseEntity<>(StringPojoParser.createJsonMapper().writeValueAsString(viewDescriptor), HttpStatus.OK); + } catch (JacksonException e) { + return new ResponseEntity<>("ERROR: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } + } + return null; + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** + * Creates a pre-configured {@link HttpHeaders} instance with + * {@code Content-Type: application/json}. + * + * @return JSON HTTP headers + */ + static HttpHeaders jsonHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + // ------------------------------------------------------------------------- + // Inner DTOs + // ------------------------------------------------------------------------- + + /** + * Simple wrapper that pairs a single entity payload with a status message. + */ + public static class SimpleResult { + + private Object data; + private String response; + + /** + * Creates a new {@code SimpleResult}. + * + * @param content the entity data to include in the response + * @param response a human-readable status message + */ + public SimpleResult(Object content, String response) { + this.data = content; + this.response = response; + } + + /** Returns the entity data payload. */ + public Object getData() { return data; } + + /** Sets the entity data payload. */ + public void setData(Object data) { this.data = data; } + + /** Returns the status message. */ + public String getResponse() { return response; } + + /** Sets the status message. */ + public void setResponse(String response) { this.response = response; } + } + + /** + * Wraps a paginated list of entities together with optional pagination metadata. + */ + public static class ListResult { + + private List data; + + /** Pagination info; omitted from JSON when {@code null}. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private DataPaginator pageable; + + private String response; + + /** Returns the status message. */ + public String getResponse() { return response; } + + /** Sets the status message. */ + public void setResponse(String response) { this.response = response; } + + /** Returns the list of entity records for the current page. */ + public List getData() { return data; } + + /** Sets the list of entity records. */ + public void setData(List data) { this.data = data; } + + /** + * Returns the pagination metadata, or {@code null} if the result is not paginated. + * + * @return the {@link DataPaginator}, or {@code null} + */ + public DataPaginator getPageable() { return pageable; } + + /** Sets the pagination metadata. */ + public void setPageable(DataPaginator pageable) { this.pageable = pageable; } + } +} + diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java index 4a53a082..943c3a8f 100644 --- a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationController.java @@ -16,42 +16,22 @@ */ package tools.dynamia.web.navigation; -import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.servlet.http.HttpServletRequest; import org.springframework.core.annotation.Order; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import tools.dynamia.commons.ObjectOperations; -import tools.dynamia.commons.StringPojoParser; -import tools.dynamia.commons.collect.PagedList; -import tools.dynamia.commons.logger.AbstractLoggable; -import tools.dynamia.commons.logger.LoggingService; import tools.dynamia.crud.CrudPage; import tools.dynamia.domain.query.DataPaginator; -import tools.dynamia.domain.query.QueryConditions; -import tools.dynamia.domain.query.QueryParameters; import tools.dynamia.domain.services.CrudService; import tools.dynamia.domain.util.QueryBuilder; import tools.dynamia.navigation.ModuleContainer; -import tools.dynamia.navigation.NavigationRestrictions; -import tools.dynamia.navigation.Page; -import tools.dynamia.navigation.PageNotFoundException; -import tools.dynamia.viewers.Field; -import tools.dynamia.viewers.JsonView; -import tools.dynamia.viewers.JsonViewDescriptorDeserializer; import tools.dynamia.viewers.ViewDescriptor; -import tools.dynamia.viewers.util.Viewers; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.JsonNode; +import tools.dynamia.web.navigation.RestNavigationContext.ListResult; +import tools.dynamia.web.navigation.RestNavigationContext.SimpleResult; import java.util.List; -import java.util.Map; -import java.util.Set; /** * REST controller that handles CRUD operations on entities exposed through the application's @@ -59,66 +39,48 @@ * definition, and this controller maps HTTP requests to the appropriate persistence operations * via {@link CrudService}. * - *

Supported operations per entity path:

+ *

This class acts as a thin delegator: all logic is implemented in focused + * operation classes and shared utilities:

*
    - *
  • GET (list) — paginated collection with optional query conditions
  • - *
  • GET (single) — retrieve one entity by ID
  • - *
  • POST — create a new entity from a JSON payload
  • - *
  • PUT — update an existing entity by ID
  • - *
  • DELETE — remove an entity by ID
  • + *
  • {@link RestNavigationContext} — shared state, descriptor lookup, response builders
  • + *
  • {@link RestNavigationQuerySupport} — filters, sorting, pagination helpers
  • + *
  • {@link RestNavigationReadOperation} — GET (list) and GET (single)
  • + *
  • {@link RestNavigationCreateOperation} — POST
  • + *
  • {@link RestNavigationUpdateOperation} — PUT
  • + *
  • {@link RestNavigationDeleteOperation} — DELETE
  • *
* - *

All responses are serialized as JSON using the entity's registered {@link ViewDescriptor}. - * Access control is enforced via {@link NavigationRestrictions}.

+ *

All public {@code static} methods are preserved for backward compatibility with external + * callers (e.g., {@link RestApiNavigationConfiguration}, custom REST customizers).

* * @author Mario A. Serrano Leones */ @RestController("restNavigationController") @Order(1000) -public class RestNavigationController extends AbstractLoggable { +public class RestNavigationController { - /** Default number of items returned per page when no {@code size} parameter is provided. */ - private static final int DEFAULT_PAGINATION_SIZE = 50; - - private static final String JSON_FORM = "json-form"; - private static final String JSON = "json"; - private static final String FORM = "form"; - - private final ModuleContainer moduleContainer; - private final CrudService crudService; + private final RestNavigationContext ctx; + private final RestNavigationReadOperation readOp; + private final RestNavigationCreateOperation createOp; + private final RestNavigationUpdateOperation updateOp; + private final RestNavigationDeleteOperation deleteOp; /** - * Constructs a new {@code RestNavigationController}. + * Constructs a new {@code RestNavigationController} and initialises all operation delegates. * * @param moduleContainer the container used to resolve navigation pages by path * @param crudService the service used to perform CRUD operations on entities */ public RestNavigationController(ModuleContainer moduleContainer, CrudService crudService) { - this.moduleContainer = moduleContainer; - this.crudService = crudService; - } - - // ------------------------------------------------------------------------- - // Path extraction - // ------------------------------------------------------------------------- - - /** - * Extracts the navigation path from the incoming request URI, stripping the - * leading {@code /api/} prefix when present. - * - * @param request the current HTTP request - * @return the normalized navigation path - */ - private String getPath(HttpServletRequest request) { - String path = request.getRequestURI(); - if (path.startsWith("/api/")) { - path = path.replaceFirst("/api/", ""); - } - return path; + this.ctx = new RestNavigationContext(moduleContainer, crudService); + this.readOp = new RestNavigationReadOperation(ctx); + this.createOp = new RestNavigationCreateOperation(ctx); + this.updateOp = new RestNavigationUpdateOperation(ctx); + this.deleteOp = new RestNavigationDeleteOperation(ctx); } // ------------------------------------------------------------------------- - // Route entry points (delegators) + // Route entry points // ------------------------------------------------------------------------- /** @@ -128,7 +90,7 @@ private String getPath(HttpServletRequest request) { * @return a JSON response containing the paginated entity list */ public ResponseEntity routeReadAll(HttpServletRequest request) { - return readAll(getPath(request), request); + return readOp.readAll(ctx.getPath(request), request); } /** @@ -136,10 +98,10 @@ public ResponseEntity routeReadAll(HttpServletRequest request) { * * @param id the entity identifier * @param request the current HTTP request - * @return a JSON response containing the requested entity, or 404 if not found + * @return a JSON response containing the requested entity */ public ResponseEntity routeReadOne(@PathVariable Long id, HttpServletRequest request) { - return readOne(getPath(request).replace("/" + id, ""), id, request); + return readOp.readOne(ctx.getPath(request).replace("/" + id, ""), id, request); } /** @@ -150,7 +112,7 @@ public ResponseEntity routeReadOne(@PathVariable Long id, HttpServletReq * @return a JSON response containing the persisted entity */ public ResponseEntity routeCreate(@RequestBody String jsonData, HttpServletRequest request) { - return create(getPath(request), jsonData, request); + return createOp.create(ctx.getPath(request), jsonData, request); } /** @@ -159,10 +121,10 @@ public ResponseEntity routeCreate(@RequestBody String jsonData, HttpServ * @param id the entity identifier * @param jsonData the JSON patch data with updated field values * @param request the current HTTP request - * @return a JSON response containing the updated entity, or 404 if not found + * @return a JSON response containing the updated entity */ public ResponseEntity routeUpdate(@PathVariable Long id, @RequestBody String jsonData, HttpServletRequest request) { - return update(getPath(request).replace("/" + id, ""), id, jsonData, request); + return updateOp.update(ctx.getPath(request).replace("/" + id, ""), id, jsonData, request); } /** @@ -170,625 +132,97 @@ public ResponseEntity routeUpdate(@PathVariable Long id, @RequestBody St * * @param id the entity identifier * @param request the current HTTP request - * @return a JSON response containing the deleted entity's data, or 404 if not found + * @return a JSON response containing the deleted entity's last known state */ public ResponseEntity routeDelete(@PathVariable Long id, HttpServletRequest request) { - return delete(getPath(request).replace("/" + id, ""), id, request); + return deleteOp.delete(ctx.getPath(request).replace("/" + id, ""), id, request); } // ------------------------------------------------------------------------- - // Internal CRUD operations + // Public static API — preserved for backward compatibility // ------------------------------------------------------------------------- /** - * Reads all entities of the type associated with the given navigation {@code path}, - * applying optional query conditions and pagination. - * - * @param path the navigation path resolving to a {@link CrudPage} - * @param request the current HTTP request (used for pagination and metadata params) - * @return a paginated JSON response or a metadata response when {@code _metadata} is requested - */ - private ResponseEntity readAll(String path, HttpServletRequest request) { - CrudPage page = findCrudPage(path); - Class entityClass = page.getEntityClass(); - - ViewDescriptor readDescriptor = getJsonTableDescriptor(entityClass); - ResponseEntity metadata = getMetadata(request, readDescriptor); - if (metadata != null) { - return metadata; - } - - QueryBuilder query = QueryBuilder.select().from(entityClass, "e"); - QueryParameters pageParams = (QueryParameters) page.getAttribute("queryParameters"); - if (pageParams != null) { - query.where(pageParams); - } - parseConditions(query, readDescriptor); - applyRequestFilters(request, query, readDescriptor); - - int pageSize = getParameterNumber(request, "size"); - int currentPage = getParameterNumber(request, "page"); - if (pageSize == 0) { - pageSize = DEFAULT_PAGINATION_SIZE; - } - query.getQueryParameters().paginate(pageSize); - - List content = crudService.executeQuery(query); - DataPaginator paginator = query.getQueryParameters().getPaginator(); - if (paginator != null) { - paginator.setPage(currentPage); - } - - return buildJsonResponse(readDescriptor, buildListResult(content, paginator, currentPage), "OK"); - } - - /** - * Builds a {@link ListResult} from a raw content list, handling paged data sources - * when the content is a {@link PagedList}. - * - * @param content the raw list returned by the query - * @param paginator the {@link DataPaginator} associated with the query, may be {@code null} - * @param currenPage the requested page number (1-based); ignored when {@code <= 0} - * @return a populated {@link ListResult} ready for serialization - */ - public static ListResult buildListResult(List content, DataPaginator paginator, int currenPage) { - ListResult result = new ListResult(); - if (content instanceof PagedList pagedList && paginator != null) { - if (currenPage > 0) { - pagedList.getDataSource().setActivePage(currenPage); - } - result.setData(pagedList.getDataSource().getPageData()); - result.setPageable(paginator); - } else { - result.setData(content); - } - result.setResponse("OK"); - return result; - } - - /** - * Reads a single entity by its {@code id} from the {@link CrudPage} resolved by {@code path}. - * - * @param path the navigation path resolving to a {@link CrudPage} - * @param id the entity identifier - * @param request the current HTTP request - * @return a JSON response with the entity, or HTTP 404 if not found - */ - private ResponseEntity readOne(String path, Long id, HttpServletRequest request) { - CrudPage page = findCrudPage(path); - Class entityClass = page.getEntityClass(); - - ViewDescriptor readDescriptor = getJsonFormDescriptor(entityClass); - ResponseEntity metadata = getMetadata(request, readDescriptor); - if (metadata != null) { - return metadata; - } - - @SuppressWarnings("unchecked") Object result = crudService.find(entityClass, id); - if (result == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body("Entity " + entityClass.getSimpleName() + " with id " + id + " not found\n"); - } - - return buildJsonResponse(readDescriptor, result, "OK"); - } - - /** - * Builds a JSON response for a single entity, automatically resolving its - * {@link ViewDescriptor} via {@link #getJsonFormDescriptor(Class)}. - * - * @param result the entity to serialize - * @return a {@code 200 OK} JSON response + * @see RestNavigationContext#buildJsonResponse(Object) */ public static ResponseEntity buildJsonResponse(Object result) { - ViewDescriptor descriptor = getJsonFormDescriptor(result.getClass()); - return buildJsonResponse(descriptor, result, "ok"); + return RestNavigationContext.buildJsonResponse(result); } /** - * Builds a JSON response wrapping a single entity result inside a {@link SimpleResult}. - * Falls back to generic JSON serialization when no {@link ViewDescriptor} is available. - * - * @param readDescriptor the view descriptor controlling field serialization; may be {@code null} - * @param result the entity to serialize - * @param message the status message included in the response body - * @return a {@code 200 OK} JSON response + * @see RestNavigationContext#buildJsonResponse(ViewDescriptor, Object, String) */ - public static ResponseEntity buildJsonResponse(ViewDescriptor readDescriptor, Object result, String message) { - HttpHeaders headers = jsonHeaders(); - SimpleResult resultWrapper = new SimpleResult(result, message); - if (readDescriptor != null) { - return new ResponseEntity<>(new JsonView<>(resultWrapper, readDescriptor).renderJson(), headers, HttpStatus.OK); - } - return new ResponseEntity<>(StringPojoParser.convertPojoToJson(result), headers, HttpStatus.OK); + public static ResponseEntity buildJsonResponse(ViewDescriptor descriptor, Object result, String message) { + return RestNavigationContext.buildJsonResponse(descriptor, result, message); } /** - * Builds a JSON response for a paginated list result. - * Falls back to generic JSON serialization when no {@link ViewDescriptor} is available. - * - * @param readDescriptor the view descriptor controlling field serialization; may be {@code null} - * @param result the {@link ListResult} to serialize - * @param message the status message (currently unused in the body for list results) - * @return a {@code 200 OK} JSON response + * @see RestNavigationContext#buildJsonResponse(ViewDescriptor, ListResult, String) */ - public static ResponseEntity buildJsonResponse(ViewDescriptor readDescriptor, ListResult result, String message) { - HttpHeaders headers = jsonHeaders(); - if (readDescriptor != null) { - return new ResponseEntity<>(new JsonView<>(result, readDescriptor).renderJson(), headers, HttpStatus.OK); - } - return new ResponseEntity<>(StringPojoParser.convertPojoToJson(result), headers, HttpStatus.OK); + public static ResponseEntity buildJsonResponse(ViewDescriptor descriptor, ListResult result, String message) { + return RestNavigationContext.buildJsonResponse(descriptor, result, message); } /** - * Resolves the JSON form {@link ViewDescriptor} for the given entity class, - * without auto-creating one when none is found. - * - * @param entityClass the entity class to look up - * @return the resolved {@link ViewDescriptor}, or {@code null} if not found + * @see RestNavigationContext#getJsonFormDescriptor(Class) */ public static ViewDescriptor getJsonFormDescriptor(Class entityClass) { - return getJsonFormDescriptor(entityClass, false); + return RestNavigationContext.getJsonFormDescriptor(entityClass); } /** - * Resolves the JSON form {@link ViewDescriptor} for the given entity class. - * The lookup order is: {@code json-form} → {@code json} → {@code form}. - * When {@code autocreate} is {@code true} and no descriptor is found, a default - * {@code form} descriptor is generated. - * - * @param entityClass the entity class to look up - * @param autocreate {@code true} to auto-generate a form descriptor when none exists - * @return the resolved (or auto-generated) {@link ViewDescriptor}, or {@code null} + * @see RestNavigationContext#getJsonFormDescriptor(Class, boolean) */ public static ViewDescriptor getJsonFormDescriptor(Class entityClass, boolean autocreate) { - ViewDescriptor descriptor = Viewers.findViewDescriptor(entityClass, JSON_FORM); - if (descriptor == null) { - descriptor = Viewers.findViewDescriptor(entityClass, JSON); - } - if (descriptor == null) { - descriptor = Viewers.findViewDescriptor(entityClass, FORM); - } - if (descriptor == null && autocreate) { - descriptor = Viewers.getViewDescriptor(entityClass, FORM); - } - return descriptor; + return RestNavigationContext.getJsonFormDescriptor(entityClass, autocreate); } /** - * Resolves the JSON table {@link ViewDescriptor} for the given entity class. - * The lookup order is: {@code json} → {@code tree} → {@code table}. - * - * @param entityClass the entity class to look up - * @return the resolved {@link ViewDescriptor}, or {@code null} if none exists + * @see RestNavigationContext#getJsonTableDescriptor(Class) */ public static ViewDescriptor getJsonTableDescriptor(Class entityClass) { - ViewDescriptor descriptor = Viewers.findViewDescriptor(entityClass, JSON); - if (descriptor == null) { - descriptor = Viewers.findViewDescriptor(entityClass, "tree"); - } - if (descriptor == null) { - descriptor = Viewers.findViewDescriptor(entityClass, "table"); - } - return descriptor; + return RestNavigationContext.getJsonTableDescriptor(entityClass); } /** - * Creates a new entity by parsing the supplied JSON payload and persisting it - * through {@link CrudService}. - * - * @param path the navigation path resolving to a {@link CrudPage} - * @param jsonData the JSON representation of the new entity - * @param request the current HTTP request (unused, reserved for future use) - * @return a JSON response containing the newly created entity + * @see RestNavigationReadOperation#buildListResult(List, DataPaginator, int) */ - private ResponseEntity create(String path, String jsonData, HttpServletRequest request) { - CrudPage page = findCrudPage(path); - Class entityClass = page.getEntityClass(); - - ViewDescriptor descriptor = getJsonFormDescriptor(entityClass, true); - JsonView jsonView = new JsonView(descriptor); - jsonView.parse(jsonData); - Object newEntity = crudService.create(jsonView.getValue()); - - return buildJsonResponse(descriptor, newEntity, "Created Successfully"); + public static ListResult buildListResult(List content, DataPaginator paginator, int currentPage) { + return RestNavigationReadOperation.buildListResult(content, paginator, currentPage); } /** - * Updates an existing entity by applying the JSON patch fields to the persistent - * instance identified by {@code id}. - * - * @param path the navigation path resolving to a {@link CrudPage} - * @param id the entity identifier - * @param jsonData the JSON object containing the fields to update - * @param request the current HTTP request (unused, reserved for future use) - * @return a JSON response with the updated entity, or HTTP 404 if not found - */ - private ResponseEntity update(String path, Long id, String jsonData, HttpServletRequest request) { - CrudPage page = findCrudPage(path); - Class entityClass = page.getEntityClass(); - - @SuppressWarnings("unchecked") final Object entity = crudService.find(entityClass, id); - if (entity == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body("Entity " + entityClass.getSimpleName() + " with id " + id + " not found"); - } - - ViewDescriptor descriptor = getJsonFormDescriptor(entityClass, true); - try { - JsonNode node = StringPojoParser.createJsonMapper().readTree(jsonData); - node.properties().forEach(entry -> { - Field field = descriptor.getField(entry.getKey()); - if (field != null) { - Object fieldValue = JsonViewDescriptorDeserializer.getNodeValue(field.getPropertyInfo(), entry.getValue()); - ObjectOperations.invokeSetMethod(entity, field.getPropertyInfo(), fieldValue); - } - }); - } catch (JacksonException e) { - log("Error updating entity", e); - } - - return buildJsonResponse(descriptor, crudService.update(entity), "Updated Successfully"); - } - - /** - * Deletes the entity identified by {@code id} and returns its last known state. - * - * @param path the navigation path resolving to a {@link CrudPage} - * @param id the entity identifier - * @param request the current HTTP request (unused, reserved for future use) - * @return a JSON response with the deleted entity's data, or HTTP 404 if not found - */ - private ResponseEntity delete(String path, Long id, HttpServletRequest request) { - CrudPage page = findCrudPage(path); - Class entityClass = page.getEntityClass(); - - Object result = crudService.find(entityClass, id); - if (result == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body("Entity " + entityClass.getSimpleName() + " with id " + id + " not found\n"); - } - - crudService.delete(entityClass, id); - return buildJsonResponse(getJsonFormDescriptor(entityClass), result, "Deleted Successfully"); - } - - // ------------------------------------------------------------------------- - // Utility / helper methods - // ------------------------------------------------------------------------- - - /** - * Applies any JPQL {@code conditions} declared in the {@link ViewDescriptor}'s parameters - * to the given {@link QueryBuilder}. Each condition string is appended as an AND clause. - * - * @param query the query builder to augment - * @param descriptor the view descriptor that may contain a {@code conditions} parameter map + * @see RestNavigationQuerySupport#parseConditions(QueryBuilder, ViewDescriptor) */ public static void parseConditions(QueryBuilder query, ViewDescriptor descriptor) { - try { - if (descriptor.getParams().containsKey("conditions")) { - @SuppressWarnings("unchecked") Map conditions = - (Map) descriptor.getParams().get("conditions"); - conditions.forEach((k, v) -> query.and(v)); - } - } catch (Exception e) { - LoggingService.get(RestNavigationController.class).error("Error parsing conditions", e); - } + RestNavigationQuerySupport.parseConditions(query, descriptor); } /** - * Applies dynamic field filters derived from HTTP query parameters to the given {@link QueryBuilder}. - * - *

Any request parameter whose name does not start with {@code _} and is not a reserved - * pagination keyword ({@code page}, {@code size}) is treated as a potential field filter. - * The filter value is matched against the entity field registered in the {@link ViewDescriptor} - * and the appropriate {@link QueryConditions} condition is selected based on the field's Java type:

- * - *
    - *
  • {@link String} — {@code LIKE} with auto-searchable wildcard wrapping
  • - *
  • {@link Number} / numeric primitives — exact equality ({@code =})
  • - *
  • {@link Boolean} / {@code boolean} — exact equality ({@code =})
  • - *
  • {@link Enum} subtypes — exact equality ({@code =}) using {@link Enum#valueOf}
  • - *
  • Any other type — skipped; not safe to cast without type information
  • - *
- * - *

Parameters for fields not present in the descriptor are silently ignored.

- * - *

Usage example:

- *
{@code GET /api/users?name=john&status=ACTIVE&age=30 }
- * - * @param request the current HTTP request carrying the filter parameters - * @param query the query builder to augment with filter conditions - * @param descriptor the view descriptor used to resolve field metadata; if {@code null} this - * method does nothing + * @see RestNavigationQuerySupport#applyRequestFilters(HttpServletRequest, QueryBuilder, ViewDescriptor) */ public static void applyRequestFilters(HttpServletRequest request, QueryBuilder query, ViewDescriptor descriptor) { - if (descriptor == null) { - return; - } - - // Parameter names that are reserved for pagination / internal use and must not be treated as filters. - Set RESERVED_PARAMS = Set.of("page", "size"); - - request.getParameterMap().forEach((paramName, values) -> { - // Skip internal (_) params and pagination keywords - if (paramName.startsWith("_") || RESERVED_PARAMS.contains(paramName) || values.length == 0) { - return; - } - - Field field = descriptor.getField(paramName); - if (field == null) { - return; // Unknown field — ignore silently - } - - Class fieldType = field.getFieldClass(); - if (fieldType == null && field.getPropertyInfo() != null) { - fieldType = field.getPropertyInfo().getType(); - } - if (fieldType == null) { - return; // Cannot determine type — skip - } - - String rawValue = values[0]; - if (rawValue == null || rawValue.isBlank()) { - return; - } - - try { - if (fieldType == String.class) { - // LIKE with auto-searchable wildcard (e.g., "john" → "%john%") - query.and(paramName, QueryConditions.like(rawValue)); - - } else if (Number.class.isAssignableFrom(fieldType) || fieldType.isPrimitive() && fieldType != boolean.class) { - // Numeric equality — parse to the right numeric type - Object numericValue = parseNumber(rawValue, fieldType); - if (numericValue != null) { - query.and(paramName, QueryConditions.eq(numericValue)); - } - - } else if (fieldType == Boolean.class || fieldType == boolean.class) { - boolean boolValue = "true".equalsIgnoreCase(rawValue) || "1".equals(rawValue); - query.and(paramName, QueryConditions.eq(boolValue)); - - } else if (fieldType.isEnum()) { - @SuppressWarnings({"unchecked", "rawtypes"}) - Object enumValue = Enum.valueOf((Class) fieldType, rawValue.toUpperCase()); - query.and(paramName, QueryConditions.eq(enumValue)); - - } - // Date, entity references, collections, etc. are intentionally skipped: - // they require more complex handling and are out of scope for simple URL filtering. - } catch (Exception e) { - LoggingService.get(RestNavigationController.class) - .warn("Ignoring filter param '" + paramName + "=" + rawValue + "': " + e.getMessage()); - } - }); + RestNavigationQuerySupport.applyRequestFilters(request, query, descriptor); } /** - * Parses a raw string value into the target numeric type. - * - *

Supports {@link Integer}, {@code int}, {@link Long}, {@code long}, - * {@link Double}, {@code double}, {@link Float}, {@code float}, - * {@link Short}, {@code short}, {@link Byte}, {@code byte}, - * and {@link java.math.BigDecimal}.

- * - * @param raw the raw string to parse - * @param targetType the target numeric {@link Class} - * @return the parsed number, or {@code null} if the type is not supported + * @see RestNavigationQuerySupport#applyRequestSorting(HttpServletRequest, QueryBuilder, ViewDescriptor) */ - private static Object parseNumber(String raw, Class targetType) { - if (targetType == Integer.class || targetType == int.class) return Integer.parseInt(raw); - if (targetType == Long.class || targetType == long.class) return Long.parseLong(raw); - if (targetType == Double.class || targetType == double.class) return Double.parseDouble(raw); - if (targetType == Float.class || targetType == float.class) return Float.parseFloat(raw); - if (targetType == Short.class || targetType == short.class) return Short.parseShort(raw); - if (targetType == Byte.class || targetType == byte.class) return Byte.parseByte(raw); - if (targetType == java.math.BigDecimal.class) return new java.math.BigDecimal(raw); - return null; + public static void applyRequestSorting(HttpServletRequest request, QueryBuilder query, ViewDescriptor descriptor) { + RestNavigationQuerySupport.applyRequestSorting(request, query, descriptor); } /** - * Reads an integer request parameter by name. Returns {@code 0} if the parameter - * is absent or cannot be parsed as an integer. - * - * @param request the current HTTP request - * @param name the name of the request parameter - * @return the parsed integer value, or {@code 0} if not present / invalid + * @see RestNavigationQuerySupport#getParameterNumber(HttpServletRequest, String) */ public static int getParameterNumber(HttpServletRequest request, String name) { - String value = request.getParameter(name); - if (value != null) { - try { - return Integer.parseInt(value); - } catch (NumberFormatException ignored) { - // fall through and return 0 - } - } - return 0; + return RestNavigationQuerySupport.getParameterNumber(request, name); } /** - * Returns a JSON {@link ResponseEntity} containing the serialized {@link ViewDescriptor} - * when the request includes the {@code _metadata} query parameter. Returns {@code null} - * when the condition is not met, allowing normal processing to continue. - * - * @param request the current HTTP request - * @param viewDescriptor the descriptor to serialize; if {@code null} this method returns {@code null} - * @return a metadata response, or {@code null} if not a metadata request + * @see RestNavigationContext#getMetadata(HttpServletRequest, ViewDescriptor) */ public static ResponseEntity getMetadata(HttpServletRequest request, ViewDescriptor viewDescriptor) { - if (viewDescriptor != null && request.getParameter("_metadata") != null) { - try { - return new ResponseEntity<>(StringPojoParser.createJsonMapper().writeValueAsString(viewDescriptor), HttpStatus.OK); - } catch (JacksonException e) { - return new ResponseEntity<>("ERROR: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); - } - } - return null; + return RestNavigationContext.getMetadata(request, viewDescriptor); } - - /** - * Resolves a {@link CrudPage} by navigation {@code path}, verifying access restrictions. - * First attempts an exact path match, then falls back to a pretty/virtual-path lookup. - * - * @param path the navigation path to resolve - * @return the matching {@link CrudPage} - * @throws PageNotFoundException if the path does not resolve to a {@link CrudPage} - * or the resolved page is of the wrong type - */ - private CrudPage findCrudPage(String path) { - Page page; - try { - page = moduleContainer.findPage(path); - } catch (PageNotFoundException e) { - page = moduleContainer.findPageByPrettyVirtualPath(path); - } - if (page instanceof CrudPage crudPage) { - NavigationRestrictions.verifyAccess(page); - return crudPage; - } - throw new PageNotFoundException("Invalid Path " + path); - } - - /** - * Creates a pre-configured {@link HttpHeaders} instance with - * {@code Content-Type: application/json}. - * - * @return JSON HTTP headers - */ - private static HttpHeaders jsonHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - return headers; - } - - // ------------------------------------------------------------------------- - // Inner result types - // ------------------------------------------------------------------------- - - /** - * Simple wrapper that pairs a single entity payload with a status message. - */ - static class SimpleResult { - - private Object data; - private String response; - - /** - * Creates a new {@code SimpleResult}. - * - * @param content the entity data to include in the response - * @param response a human-readable status message - */ - public SimpleResult(Object content, String response) { - this.data = content; - this.response = response; - } - - /** - * Returns the entity data payload. - * - * @return the data object - */ - public Object getData() { - return data; - } - - /** - * Sets the entity data payload. - * - * @param data the data object - */ - public void setData(Object data) { - this.data = data; - } - - /** - * Returns the status message. - * - * @return the response message - */ - public String getResponse() { - return response; - } - - /** - * Sets the status message. - * - * @param response the response message - */ - public void setResponse(String response) { - this.response = response; - } - } - - /** - * Wraps a paginated list of entities together with optional pagination metadata. - */ - static class ListResult { - - private List data; - - /** Pagination info; omitted from JSON when {@code null}. */ - @JsonInclude(JsonInclude.Include.NON_NULL) - private DataPaginator pageable; - - private String response; - - /** - * Returns the status message. - * - * @return the response message - */ - public String getResponse() { - return response; - } - - /** - * Sets the status message. - * - * @param response the response message - */ - public void setResponse(String response) { - this.response = response; - } - - /** - * Returns the list of entity records for the current page. - * - * @return the data list - */ - public List getData() { - return data; - } - - /** - * Sets the list of entity records. - * - * @param data the data list - */ - public void setData(List data) { - this.data = data; - } - - /** - * Returns the pagination metadata, or {@code null} if the result is not paginated. - * - * @return the {@link DataPaginator}, or {@code null} - */ - public DataPaginator getPageable() { - return pageable; - } - - /** - * Sets the pagination metadata. - * - * @param pageable the {@link DataPaginator} to attach - */ - public void setPageable(DataPaginator pageable) { - this.pageable = pageable; - } - } - } diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationCreateOperation.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationCreateOperation.java new file mode 100644 index 00000000..8ab3e5a5 --- /dev/null +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationCreateOperation.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.web.navigation; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import tools.dynamia.crud.CrudPage; +import tools.dynamia.viewers.JsonView; +import tools.dynamia.viewers.ViewDescriptor; + +/** + * Handles the create operation for the REST navigation API. + * + *

Accepts a JSON body, parses it into a new entity instance using the entity's + * registered {@link ViewDescriptor}, persists it via {@link tools.dynamia.domain.services.CrudService}, + * and returns the persisted entity as a JSON response.

+ * + * @author Mario A. Serrano Leones + * @see RestNavigationContext + */ +public class RestNavigationCreateOperation { + + private final RestNavigationContext ctx; + + /** + * Constructs a new {@code RestNavigationCreateOperation}. + * + * @param ctx the shared navigation context providing infrastructure dependencies + */ + public RestNavigationCreateOperation(RestNavigationContext ctx) { + this.ctx = ctx; + } + + /** + * Creates a new entity by parsing the supplied JSON payload and persisting it. + * + *

The entity class and its {@link ViewDescriptor} are resolved from the {@link CrudPage} + * associated with {@code path}. If no descriptor exists, one is auto-generated from the + * entity's {@code form} view definition.

+ * + * @param path the navigation path resolving to a {@link CrudPage} + * @param jsonData the JSON representation of the new entity + * @param request the current HTTP request (reserved for future use) + * @return a {@code 200 OK} JSON response containing the newly persisted entity + */ + public ResponseEntity create(String path, String jsonData, HttpServletRequest request) { + CrudPage page = ctx.findCrudPage(path); + Class entityClass = page.getEntityClass(); + + ViewDescriptor descriptor = RestNavigationContext.getJsonFormDescriptor(entityClass, true); + JsonView jsonView = new JsonView(descriptor); + jsonView.parse(jsonData); + Object newEntity = ctx.getCrudService().create(jsonView.getValue()); + + return RestNavigationContext.buildJsonResponse(descriptor, newEntity, "Created Successfully"); + } +} + diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationDeleteOperation.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationDeleteOperation.java new file mode 100644 index 00000000..9557f1c3 --- /dev/null +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationDeleteOperation.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.web.navigation; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import tools.dynamia.crud.CrudPage; +import tools.dynamia.navigation.PageNotFoundException; + +/** + * Handles the delete operation for the REST navigation API. + * + *

Loads the entity to verify it exists (returning its last known state in the response), + * then removes it via {@link tools.dynamia.domain.services.CrudService#delete}.

+ * + * @author Mario A. Serrano Leones + * @see RestNavigationContext + */ +public class RestNavigationDeleteOperation { + + private final RestNavigationContext ctx; + + /** + * Constructs a new {@code RestNavigationDeleteOperation}. + * + * @param ctx the shared navigation context providing infrastructure dependencies + */ + public RestNavigationDeleteOperation(RestNavigationContext ctx) { + this.ctx = ctx; + } + + /** + * Deletes the entity identified by {@code id} and returns its last known state. + * + * @param path the navigation path resolving to a {@link CrudPage} + * @param id the entity identifier + * @param request the current HTTP request (reserved for future use) + * @return a {@code 200 OK} JSON response containing the deleted entity's data + * @throws PageNotFoundException if no entity with the given {@code id} exists + */ + public ResponseEntity delete(String path, Long id, HttpServletRequest request) { + CrudPage page = ctx.findCrudPage(path); + Class entityClass = page.getEntityClass(); + + Object result = ctx.getCrudService().find(entityClass, id); + if (result == null) { + throw new PageNotFoundException(entityClass.getSimpleName() + " with id " + id + " not found"); + } + + ctx.getCrudService().delete(entityClass, id); + return RestNavigationContext.buildJsonResponse(RestNavigationContext.getJsonFormDescriptor(entityClass), result, "Deleted Successfully"); + } +} + diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationQuerySupport.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationQuerySupport.java new file mode 100644 index 00000000..72e3bc84 --- /dev/null +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationQuerySupport.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.web.navigation; + +import jakarta.servlet.http.HttpServletRequest; +import tools.dynamia.commons.logger.LoggingService; +import tools.dynamia.domain.query.QueryConditions; +import tools.dynamia.domain.util.QueryBuilder; +import tools.dynamia.viewers.Field; +import tools.dynamia.viewers.ViewDescriptor; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.Set; + +/** + * Utility class that encapsulates all query-building support for the REST navigation layer. + * + *

Provides three independent, stateless helpers consumed by the read operation class:

+ *
    + *
  • {@link #parseConditions} — static JPQL conditions declared in a {@link ViewDescriptor}
  • + *
  • {@link #applyRequestFilters} — dynamic per-field filters from HTTP query parameters
  • + *
  • {@link #applyRequestSorting} — dynamic ORDER BY from {@code _sort} / {@code _order} parameters
  • + *
+ * + *

All methods are {@code static} and {@code public} so they can be reused from outside + * the package (e.g., custom operation subclasses or tests).

+ * + * @author Mario A. Serrano Leones + */ +public final class RestNavigationQuerySupport { + + /** Reserved parameter names that must never be treated as field filters. */ + private static final Set RESERVED_PARAMS = Set.of("page", "size"); + + private RestNavigationQuerySupport() { + // utility class — no instances + } + + // ------------------------------------------------------------------------- + // Static conditions from ViewDescriptor + // ------------------------------------------------------------------------- + + /** + * Applies any JPQL {@code conditions} declared in the {@link ViewDescriptor}'s parameters + * to the given {@link QueryBuilder}. Each condition string is appended as an AND clause. + * + * @param query the query builder to augment + * @param descriptor the view descriptor that may contain a {@code conditions} parameter map + */ + public static void parseConditions(QueryBuilder query, ViewDescriptor descriptor) { + try { + if (descriptor != null && descriptor.getParams().containsKey("conditions")) { + @SuppressWarnings("unchecked") + Map conditions = (Map) descriptor.getParams().get("conditions"); + conditions.forEach((k, v) -> query.and(v)); + } + } catch (Exception e) { + LoggingService.get(RestNavigationQuerySupport.class).error("Error parsing conditions", e); + } + } + + // ------------------------------------------------------------------------- + // Dynamic per-field filters from request parameters + // ------------------------------------------------------------------------- + + /** + * Applies dynamic field filters derived from HTTP query parameters to the given {@link QueryBuilder}. + * + *

Any request parameter whose name does not start with {@code _} and is not a reserved + * pagination keyword ({@code page}, {@code size}) is treated as a potential field filter. + * The filter value is matched against the entity field registered in the {@link ViewDescriptor} + * and the appropriate {@link QueryConditions} condition is selected based on the field's Java type:

+ * + *
    + *
  • {@link String} — {@code LIKE} with auto-searchable wildcard wrapping
  • + *
  • {@link Number} / numeric primitives — exact equality ({@code =})
  • + *
  • {@link Boolean} / {@code boolean} — exact equality ({@code =})
  • + *
  • {@link Enum} subtypes — exact equality ({@code =}) using {@link Enum#valueOf}
  • + *
  • Any other type — skipped; not safe to cast without type information
  • + *
+ * + *

Parameters for fields not present in the descriptor are silently ignored.

+ * + *

Usage example:

+ *
{@code GET /api/users?name=john&status=ACTIVE&age=30 }
+ * + * @param request the current HTTP request carrying the filter parameters + * @param query the query builder to augment with filter conditions + * @param descriptor the view descriptor used to resolve field metadata; if {@code null} this + * method does nothing + */ + public static void applyRequestFilters(HttpServletRequest request, QueryBuilder query, ViewDescriptor descriptor) { + if (descriptor == null) { + return; + } + + request.getParameterMap().forEach((paramName, values) -> { + if (paramName.startsWith("_") || RESERVED_PARAMS.contains(paramName) || values.length == 0) { + return; + } + + Field field = descriptor.getField(paramName); + if (field == null) { + return; + } + + Class fieldType = field.getFieldClass(); + if (fieldType == null && field.getPropertyInfo() != null) { + fieldType = field.getPropertyInfo().getType(); + } + if (fieldType == null) { + return; + } + + String rawValue = values[0]; + if (rawValue == null || rawValue.isBlank()) { + return; + } + + try { + if (fieldType == String.class) { + query.and(paramName, QueryConditions.like(rawValue)); + + } else if (Number.class.isAssignableFrom(fieldType) + || (fieldType.isPrimitive() && fieldType != boolean.class)) { + Object numericValue = parseNumber(rawValue, fieldType); + if (numericValue != null) { + query.and(paramName, QueryConditions.eq(numericValue)); + } + + } else if (fieldType == Boolean.class || fieldType == boolean.class) { + boolean boolValue = "true".equalsIgnoreCase(rawValue) || "1".equals(rawValue); + query.and(paramName, QueryConditions.eq(boolValue)); + + } else if (fieldType.isEnum()) { + @SuppressWarnings({"unchecked", "rawtypes"}) + Object enumValue = Enum.valueOf((Class) fieldType, rawValue.toUpperCase()); + query.and(paramName, QueryConditions.eq(enumValue)); + } + // Date, entity references, collections → skipped (require richer handling) + } catch (Exception e) { + LoggingService.get(RestNavigationQuerySupport.class) + .warn("Ignoring filter param '" + paramName + "=" + rawValue + "': " + e.getMessage()); + } + }); + } + + // ------------------------------------------------------------------------- + // Dynamic ORDER BY from request parameters + // ------------------------------------------------------------------------- + + /** + * Applies dynamic ordering to the given {@link QueryBuilder} based on the HTTP request + * parameters {@code _sort} and {@code _order}. + * + *

Supported parameters:

+ *
    + *
  • {@code _sort} — comma-separated list of field names (e.g., {@code name,age}).
  • + *
  • {@code _order} — comma-separated list of directions ({@code asc}/{@code desc}). + * When fewer directions than fields are given, the last direction is reused. + * Defaults to {@code asc}.
  • + *
+ * + *

Each field is validated against the {@link ViewDescriptor} when one is provided; + * unknown fields are silently skipped to prevent JPQL injection.

+ * + *

Usage examples:

+ *
{@code
+     * GET /api/users?_sort=name              → ORDER BY e.name ASC
+     * GET /api/users?_sort=name&_order=desc  → ORDER BY e.name DESC
+     * GET /api/users?_sort=name,age&_order=asc,desc → ORDER BY e.name ASC, e.age DESC
+     * }
+ * + * @param request the current HTTP request + * @param query the query builder to augment with ORDER BY clauses + * @param descriptor the view descriptor used to validate field names; if {@code null}, + * field names are accepted as-is + */ + public static void applyRequestSorting(HttpServletRequest request, QueryBuilder query, ViewDescriptor descriptor) { + String sortParam = request.getParameter("_sort"); + if (sortParam == null || sortParam.isBlank()) { + return; + } + + String orderParam = request.getParameter("_order"); + String[] fields = sortParam.split(","); + String[] directions = (orderParam != null && !orderParam.isBlank()) + ? orderParam.split(",") + : new String[0]; + + for (int i = 0; i < fields.length; i++) { + String fieldName = fields[i].strip(); + if (fieldName.isEmpty()) { + continue; + } + + if (descriptor != null && descriptor.getField(fieldName) == null) { + LoggingService.get(RestNavigationQuerySupport.class) + .warn("Ignoring unknown sort field '" + fieldName + "'"); + continue; + } + + String rawDirection = (directions.length > 0) + ? directions[Math.min(i, directions.length - 1)].strip() + : "asc"; + String direction = "desc".equalsIgnoreCase(rawDirection) ? "DESC" : "ASC"; + + query.orderBy(fieldName + " " + direction); + } + } + + // ------------------------------------------------------------------------- + // Pagination param reader + // ------------------------------------------------------------------------- + + /** + * Reads an integer request parameter by name. Returns {@code 0} if the parameter + * is absent or cannot be parsed as an integer. + * + * @param request the current HTTP request + * @param name the name of the request parameter + * @return the parsed integer value, or {@code 0} if not present / invalid + */ + public static int getParameterNumber(HttpServletRequest request, String name) { + String value = request.getParameter(name); + if (value != null) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException ignored) { + // fall through + } + } + return 0; + } + + // ------------------------------------------------------------------------- + // Internal numeric parser + // ------------------------------------------------------------------------- + + /** + * Parses a raw string value into the target numeric type. + * + *

Supports {@link Integer}, {@code int}, {@link Long}, {@code long}, + * {@link Double}, {@code double}, {@link Float}, {@code float}, + * {@link Short}, {@code short}, {@link Byte}, {@code byte}, + * and {@link BigDecimal}.

+ * + * @param raw the raw string to parse + * @param targetType the target numeric {@link Class} + * @return the parsed number, or {@code null} if the type is not supported + */ + static Object parseNumber(String raw, Class targetType) { + if (targetType == Integer.class || targetType == int.class) return Integer.parseInt(raw); + if (targetType == Long.class || targetType == long.class) return Long.parseLong(raw); + if (targetType == Double.class || targetType == double.class) return Double.parseDouble(raw); + if (targetType == Float.class || targetType == float.class) return Float.parseFloat(raw); + if (targetType == Short.class || targetType == short.class) return Short.parseShort(raw); + if (targetType == Byte.class || targetType == byte.class) return Byte.parseByte(raw); + if (targetType == BigDecimal.class) return new BigDecimal(raw); + return null; + } +} + + diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationReadOperation.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationReadOperation.java new file mode 100644 index 00000000..a108ca5e --- /dev/null +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationReadOperation.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.web.navigation; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import tools.dynamia.commons.collect.PagedList; +import tools.dynamia.crud.CrudPage; +import tools.dynamia.domain.query.DataPaginator; +import tools.dynamia.domain.query.QueryParameters; +import tools.dynamia.domain.util.QueryBuilder; +import tools.dynamia.navigation.PageNotFoundException; +import tools.dynamia.viewers.ViewDescriptor; +import tools.dynamia.web.navigation.RestNavigationContext.ListResult; + +import java.util.List; + +/** + * Handles the read operations for the REST navigation API. + * + *

Covers two HTTP verbs:

+ *
    + *
  • GET (collection) — paginated list with static conditions, dynamic filters, and sorting.
  • + *
  • GET (single) — individual entity retrieval by numeric ID.
  • + *
+ * + *

Both operations honour the {@code _metadata} query parameter, which short-circuits the normal + * response and returns the entity's {@link ViewDescriptor} as JSON instead.

+ * + * @author Mario A. Serrano Leones + * @see RestNavigationQuerySupport + * @see RestNavigationContext + */ +public class RestNavigationReadOperation { + + /** Default page size when the client does not supply a {@code size} parameter. */ + private static final int DEFAULT_PAGINATION_SIZE = 50; + + private final RestNavigationContext ctx; + + /** + * Constructs a new {@code RestNavigationReadOperation}. + * + * @param ctx the shared navigation context providing infrastructure dependencies + */ + public RestNavigationReadOperation(RestNavigationContext ctx) { + this.ctx = ctx; + } + + // ------------------------------------------------------------------------- + // Read all (paginated collection) + // ------------------------------------------------------------------------- + + /** + * Returns a paginated collection of entities for the {@link CrudPage} resolved from {@code path}. + * + *

Processing pipeline:

+ *
    + *
  1. Resolve and access-check the {@link CrudPage}.
  2. + *
  3. Short-circuit with metadata when {@code _metadata} is present.
  4. + *
  5. Apply static conditions from the {@link ViewDescriptor}.
  6. + *
  7. Apply dynamic field filters from request parameters.
  8. + *
  9. Apply dynamic ordering from {@code _sort} / {@code _order}.
  10. + *
  11. Paginate using {@code page} / {@code size}.
  12. + *
+ * + * @param path the navigation path resolving to a {@link CrudPage} + * @param request the current HTTP request + * @return a paginated JSON response, or a metadata JSON response when {@code _metadata} is requested + */ + public ResponseEntity readAll(String path, HttpServletRequest request) { + CrudPage page = ctx.findCrudPage(path); + Class entityClass = page.getEntityClass(); + + ViewDescriptor descriptor = RestNavigationContext.getJsonTableDescriptor(entityClass); + ResponseEntity metadata = RestNavigationContext.getMetadata(request, descriptor); + if (metadata != null) { + return metadata; + } + + QueryBuilder query = QueryBuilder.select().from(entityClass, "e"); + QueryParameters pageParams = (QueryParameters) page.getAttribute("queryParameters"); + if (pageParams != null) { + query.where(pageParams); + } + + RestNavigationQuerySupport.parseConditions(query, descriptor); + RestNavigationQuerySupport.applyRequestFilters(request, query, descriptor); + RestNavigationQuerySupport.applyRequestSorting(request, query, descriptor); + + int pageSize = RestNavigationQuerySupport.getParameterNumber(request, "size"); + int currentPage = RestNavigationQuerySupport.getParameterNumber(request, "page"); + if (pageSize == 0) { + pageSize = DEFAULT_PAGINATION_SIZE; + } + query.getQueryParameters().paginate(pageSize); + + List content = ctx.getCrudService().executeQuery(query); + DataPaginator paginator = query.getQueryParameters().getPaginator(); + if (paginator != null) { + paginator.setPage(currentPage); + } + + return RestNavigationContext.buildJsonResponse(descriptor, buildListResult(content, paginator, currentPage), "OK"); + } + + // ------------------------------------------------------------------------- + // Read one (single entity) + // ------------------------------------------------------------------------- + + /** + * Returns a single entity identified by {@code id} from the {@link CrudPage} resolved by {@code path}. + * + * @param path the navigation path resolving to a {@link CrudPage} + * @param id the entity identifier + * @param request the current HTTP request + * @return a JSON response with the entity data + * @throws PageNotFoundException if no entity with the given {@code id} exists + */ + public ResponseEntity readOne(String path, Long id, HttpServletRequest request) { + CrudPage page = ctx.findCrudPage(path); + Class entityClass = page.getEntityClass(); + + ViewDescriptor descriptor = RestNavigationContext.getJsonFormDescriptor(entityClass); + ResponseEntity metadata = RestNavigationContext.getMetadata(request, descriptor); + if (metadata != null) { + return metadata; + } + + @SuppressWarnings("unchecked") Object result = ctx.getCrudService().find(entityClass, id); + if (result == null) { + throw new PageNotFoundException(entityClass.getSimpleName() + " with id " + id + " not found"); + } + + return RestNavigationContext.buildJsonResponse(descriptor, result, "OK"); + } + + // ------------------------------------------------------------------------- + // ListResult builder (also used by other callers) + // ------------------------------------------------------------------------- + + /** + * Builds a {@link ListResult} from a raw content list, handling paged data sources + * when the content is a {@link PagedList}. + * + * @param content the raw list returned by the query + * @param paginator the {@link DataPaginator} associated with the query; may be {@code null} + * @param currentPage the requested page number (1-based); ignored when {@code <= 0} + * @return a populated {@link ListResult} ready for serialization + */ + public static ListResult buildListResult(List content, DataPaginator paginator, int currentPage) { + ListResult result = new ListResult(); + if (content instanceof PagedList pagedList && paginator != null) { + if (currentPage > 0) { + pagedList.getDataSource().setActivePage(currentPage); + } + result.setData(pagedList.getDataSource().getPageData()); + result.setPageable(paginator); + } else { + result.setData(content); + } + result.setResponse("OK"); + return result; + } +} + diff --git a/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationUpdateOperation.java b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationUpdateOperation.java new file mode 100644 index 00000000..11d0ac31 --- /dev/null +++ b/platform/core/web/src/main/java/tools/dynamia/web/navigation/RestNavigationUpdateOperation.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.web.navigation; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import tools.dynamia.commons.ObjectOperations; +import tools.dynamia.commons.StringPojoParser; +import tools.dynamia.commons.logger.AbstractLoggable; +import tools.dynamia.crud.CrudPage; +import tools.dynamia.navigation.PageNotFoundException; +import tools.dynamia.viewers.Field; +import tools.dynamia.viewers.JsonViewDescriptorDeserializer; +import tools.dynamia.viewers.ViewDescriptor; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; + +/** + * Handles the update operation for the REST navigation API. + * + *

Applies a partial JSON patch to an existing entity: only the fields present in the + * request body are updated; all other fields retain their current persisted values. + * The entity is first loaded from the database, patched in memory, and then saved back + * via {@link tools.dynamia.domain.services.CrudService#update}.

+ * + * @author Mario A. Serrano Leones + * @see RestNavigationContext + */ +public class RestNavigationUpdateOperation extends AbstractLoggable { + + private final RestNavigationContext ctx; + + /** + * Constructs a new {@code RestNavigationUpdateOperation}. + * + * @param ctx the shared navigation context providing infrastructure dependencies + */ + public RestNavigationUpdateOperation(RestNavigationContext ctx) { + this.ctx = ctx; + } + + /** + * Updates the entity identified by {@code id} by applying the fields present in {@code jsonData}. + * + *

Processing steps:

+ *
    + *
  1. Load the entity from the database; throw {@link PageNotFoundException} if absent.
  2. + *
  3. Parse the JSON body into a {@link JsonNode} tree.
  4. + *
  5. For each field present in both the JSON and the entity's {@link ViewDescriptor}, + * invoke the corresponding setter via reflection.
  6. + *
  7. Persist the patched entity and return it as JSON.
  8. + *
+ * + * @param path the navigation path resolving to a {@link CrudPage} + * @param id the entity identifier + * @param jsonData the JSON object containing the fields to update + * @param request the current HTTP request (reserved for future use) + * @return a {@code 200 OK} JSON response containing the updated entity + * @throws PageNotFoundException if no entity with the given {@code id} exists + */ + public ResponseEntity update(String path, Long id, String jsonData, HttpServletRequest request) { + CrudPage page = ctx.findCrudPage(path); + Class entityClass = page.getEntityClass(); + + @SuppressWarnings("unchecked") final Object entity = ctx.getCrudService().find(entityClass, id); + if (entity == null) { + throw new PageNotFoundException(entityClass.getSimpleName() + " with id " + id + " not found"); + } + + ViewDescriptor descriptor = RestNavigationContext.getJsonFormDescriptor(entityClass, true); + try { + JsonNode node = StringPojoParser.createJsonMapper().readTree(jsonData); + node.properties().forEach(entry -> { + Field field = descriptor.getField(entry.getKey()); + if (field != null) { + Object fieldValue = JsonViewDescriptorDeserializer.getNodeValue(field.getPropertyInfo(), entry.getValue()); + ObjectOperations.invokeSetMethod(entity, field.getPropertyInfo(), fieldValue); + } + }); + } catch (JacksonException e) { + log("Error updating entity", e); + } + + return RestNavigationContext.buildJsonResponse(descriptor, ctx.getCrudService().update(entity), "Updated Successfully"); + } +} + From a3d3d9ac5f4311b14be29eae6708aab82bcedf44 Mon Sep 17 00:00:00 2001 From: Mario Serrano Date: Sun, 8 Mar 2026 22:57:00 -0500 Subject: [PATCH 15/15] Add JSON configuration for categories and update Book model with JsonIgnore annotation --- .../main/java/mybookstore/domain/Book.java | 2 ++ .../META-INF/descriptors/CategoryJson.yml | 2 ++ .../src/test/api/categories.http | 30 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 examples/demo-zk-books/src/test/api/categories.http diff --git a/examples/demo-zk-books/src/main/java/mybookstore/domain/Book.java b/examples/demo-zk-books/src/main/java/mybookstore/domain/Book.java index 895e6e8f..98d3b2b6 100644 --- a/examples/demo-zk-books/src/main/java/mybookstore/domain/Book.java +++ b/examples/demo-zk-books/src/main/java/mybookstore/domain/Book.java @@ -17,6 +17,7 @@ package mybookstore.domain; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -72,6 +73,7 @@ public class Book extends BaseEntity { private double discount;//percent @Transient + @JsonIgnore private boolean selected; diff --git a/examples/demo-zk-books/src/main/resources/META-INF/descriptors/CategoryJson.yml b/examples/demo-zk-books/src/main/resources/META-INF/descriptors/CategoryJson.yml index 561205fb..328db303 100644 --- a/examples/demo-zk-books/src/main/resources/META-INF/descriptors/CategoryJson.yml +++ b/examples/demo-zk-books/src/main/resources/META-INF/descriptors/CategoryJson.yml @@ -6,6 +6,8 @@ fields: name: description: subcategories: + parent.id: + params: conditions: diff --git a/examples/demo-zk-books/src/test/api/categories.http b/examples/demo-zk-books/src/test/api/categories.http new file mode 100644 index 00000000..a17a8875 --- /dev/null +++ b/examples/demo-zk-books/src/test/api/categories.http @@ -0,0 +1,30 @@ +@baseUrl=http://localhost:8484/api/library/categories + +### List categories +GET {{baseUrl}} + +### Create category +POST {{baseUrl}} +content-type: application/json + +{ + "name": "{{$random.book.genre}}", + "parent": { + "id": 1 + } +} + +### Get category by id +GET {{baseUrl}}/1 + + +### Get category by id not found +GET {{baseUrl}}/133333333 + + +### List categories metadata +GET {{baseUrl}}?_metadata + + +### Filter categories by name +GET {{baseUrl}}?name=Programmin \ No newline at end of file