diff --git a/application-engine/src/main/java/com/netgrif/application/engine/configuration/web/config/WebConfig.java b/application-engine/src/main/java/com/netgrif/application/engine/configuration/web/config/WebConfig.java index a85a1850106..bfee7865522 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/configuration/web/config/WebConfig.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/configuration/web/config/WebConfig.java @@ -1,13 +1,22 @@ package com.netgrif.application.engine.configuration.web.config; import com.netgrif.application.engine.configuration.web.filter.TrailingSlashRedirectFilter; +import com.netgrif.application.engine.serialization.FieldSelectorInterceptor; import jakarta.servlet.Filter; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration -public class WebConfig { +public class WebConfig implements WebMvcConfigurer { + + private final FieldSelectorInterceptor interceptor; + + public WebConfig(FieldSelectorInterceptor interceptor) { + this.interceptor = interceptor; + } @Bean public Filter trailingSlashRedirectFilter() { @@ -21,4 +30,9 @@ public FilterRegistrationBean trailingSlashFilter() { registrationBean.addUrlPatterns("/*"); return registrationBean; } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(interceptor); + } } diff --git a/application-engine/src/main/java/com/netgrif/application/engine/serialization/DynamicFieldSerializer.java b/application-engine/src/main/java/com/netgrif/application/engine/serialization/DynamicFieldSerializer.java new file mode 100644 index 00000000000..caf502df6e9 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/serialization/DynamicFieldSerializer.java @@ -0,0 +1,127 @@ +package com.netgrif.application.engine.serialization; + +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.introspect.BeanPropertyDefinition; + +import java.io.IOException; + +/** + * Custom JSON serializer that provides selective field serialization based on a {@link FieldSelector} configuration. + *

+ * This serializer extends {@link JsonSerializer} to enable dynamic control over which fields of an object + * are included in the JSON output. It uses a {@link FieldSelectorHolder} to access the field selector + * configuration and applies it during serialization to filter object properties. + *

+ *

+ * The serializer supports: + *

+ *

+ *

+ * Example usage: + *

+ * FieldSelector selector = new FieldSelector();
+ * selector.include("name");
+ * selector.include("address.city");
+ *
+ * FieldSelectorHolder holder = new FieldSelectorHolder(selector);
+ * DynamicFieldSerializer serializer = new DynamicFieldSerializer(holder);
+ * 
+ *

+ * + * @see JsonSerializer + * @see FieldSelector + * @see FieldSelectorHolder + */ +public class DynamicFieldSerializer extends ValueSerializer { + + /** + * Holder containing the field selector used to determine which fields should be serialized. + */ + private final FieldSelectorHolder holder; + + /** + * Constructs a new DynamicFieldSerializer with the specified field selector holder. + * + * @param holder the field selector holder that provides the selector for filtering fields during serialization + */ + public DynamicFieldSerializer(FieldSelectorHolder holder) { + this.holder = holder; + } + + /** + * Serializes the given object value to JSON using the field selector from the holder. + * This method delegates to {@link #serializeWithSelector(Object, JsonGenerator, SerializerProvider, FieldSelector)} + * with the selector obtained from the holder. + * + * @param value the object to serialize + * @param gen the JSON generator used for writing JSON content + * @param provider the serializer provider for accessing serializers + * @throws IOException if an I/O error occurs during serialization + */ + @Override + public void serialize(Object value, JsonGenerator gen, SerializationContext context) { + serializeWithSelector(value, gen, context, holder.getSelector()); + } + + /** + * Serializes the given object value to JSON based on the provided field selector. + *

+ * This method performs selective serialization by including only the fields specified in the selector. + * If the selector includes all fields, the default serialization is performed. Otherwise, it introspects + * the object's bean properties and serializes only those included in the selector. Nested field selectors + * are applied recursively for complex object structures. + *

+ * + * @param value the object to serialize, it may be null + * @param gen the JSON generator used for writing JSON content + * @param provider the serializer provider for accessing serializers and configuration + * @param selector the field selector that determines which fields to include in serialization + * @throws IOException if an I/O error occurs during serialization + */ + public void serializeWithSelector(Object value, JsonGenerator gen, SerializationContext context, FieldSelector selector) { + + if (value == null) { + gen.writeNull(); + return; + } + + if (selector.includeAll()) { + context.findValueSerializer(value.getClass()).serialize(value, gen, context); + return; + } + + JavaType type = context.constructType(value.getClass()); + BeanDescription desc = context.introspectBeanDescription(type); + + gen.writeStartObject(); + for (BeanPropertyDefinition prop : desc.findProperties()) { + + String name = prop.getName(); + if (!selector.includes(name)) { + continue; + } + + Object propValue = prop.getAccessor().getValue(value); + gen.writeName(name); + + FieldSelector nested = selector.nested(name); + if (nested.includeAll()) { + context.findValueSerializer(propValue.getClass()).serialize(propValue, gen, context); + } else { + serializeWithSelector(propValue, gen, context, nested); + } + } + gen.writeEndObject(); + } +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/serialization/FieldSelector.java b/application-engine/src/main/java/com/netgrif/application/engine/serialization/FieldSelector.java new file mode 100644 index 00000000000..d084f5fad99 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/serialization/FieldSelector.java @@ -0,0 +1,212 @@ +package com.netgrif.application.engine.serialization; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * A utility class for parsing and managing field selection specifications in JSON serialization. + *

+ * This class enables selective field inclusion during serialization by parsing a field specification + * string that supports both simple field names and nested field selections using parentheses notation. + *

+ *

+ * Field specification format: + *

+ *

+ *

+ * When no specification is provided (null or blank), the selector allows all fields (unrestricted mode). + *

+ * + * @see LocalizedEventOutcomeSerializer + * @see DynamicFieldSerializer + * @see FieldSelectorHolder + */ + +public final class FieldSelector { + /** + * Set of field names to include at the current level of serialization. + * When null, all fields are included (unrestricted mode). + */ + private final Set fields; + + /** + * Map of field names to their corresponding nested {@link FieldSelector} instances. + * Enables hierarchical field selection for complex object structures. + * When null, no nested field restrictions are applied. + */ + private final Map nested; + + /** + * Creates a new FieldSelector in unrestricted mode. + *

+ * This constructor creates a selector that allows all fields to be included + * during serialization without any restrictions. + *

+ */ + private FieldSelector() { + this(null, null); + } + + /** + * Creates a new FieldSelector with specified field selections and nested selectors. + * + * @param fields the set of field names to include at this level, or null for unrestricted mode + * @param nested the map of field names to their nested selectors, or null if no nested selections + */ + private FieldSelector(Set fields, Map nested) { + this.fields = fields; + this.nested = nested; + } + + /** + * Parses a field specification string and creates a corresponding FieldSelector. + *

+ * The parser supports: + *

    + *
  • Comma-separated field names: {@code "field1,field2,field3"}
  • + *
  • Nested field selections using parentheses: {@code "field1(nested1,nested2)"}
  • + *
  • Multiple levels of nesting: {@code "field1(nested1(deepNested1))"}
  • + *
+ *

+ *

+ * If the specification is null or blank, an unrestricted selector is returned that allows all fields. + *

+ * + * @param spec the field specification string to parse, may be null or blank + * @return a FieldSelector instance representing the parsed specification + * @throws IllegalArgumentException if the specification contains unmatched parentheses + */ + public static FieldSelector parse(String spec) { + if (spec == null || spec.isBlank()) { + return new FieldSelector(null, null); + } + + Set fields = new HashSet<>(); + Map nested = new HashMap<>(); + + int i = 0; + while (i < spec.length()) { + int commaIdx = nextCommaOrEnd(spec, i); + int parenthesisIdx = spec.indexOf('(', i); + + if (parenthesisIdx != -1 && parenthesisIdx < commaIdx) { + String name = spec.substring(i, parenthesisIdx).trim(); + int closeParenthesisIdx = findMatchingParen(spec, parenthesisIdx); + String inner = spec.substring(parenthesisIdx + 1, closeParenthesisIdx); + fields.add(name); + nested.put(name, parse(inner)); + i = closeParenthesisIdx + 1; + if (i < spec.length() && spec.charAt(i) == ',') i++; + } else { + String name = spec.substring(i, commaIdx).trim(); + if (!name.isEmpty()) fields.add(name); + i = commaIdx + 1; + } + } + return new FieldSelector(fields, nested); + } + + /** + * Checks whether the specified field should be included in serialization. + *

+ * A field is included if: + *

    + *
  • The selector is in unrestricted mode (fields is null), or
  • + *
  • The field is explicitly listed in the fields set, or
  • + *
  • The field has a nested selector defined
  • + *
+ *

+ * + * @param field the name of the field to check + * @return true if the field should be included, false otherwise + */ + public boolean includes(String field) { + if (fields == null) return true; + if (fields.contains(field)) return true; + return nested != null && nested.containsKey(field); + } + + /** + * Checks whether this selector is in unrestricted mode, allowing all fields. + *

+ * Unrestricted mode occurs when no field specification was provided during parsing + * (i.e., the specification was null or blank). + *

+ * + * @return true if all fields should be included without restrictions, false otherwise + */ + public boolean includeAll() { + return fields == null; + } + + /** + * Retrieves the nested FieldSelector for the specified field. + *

+ * If no nested selector is defined for the field, returns an unrestricted selector + * that allows all fields at the nested level. + *

+ * + * @param field the name of the field whose nested selector to retrieve + * @return the nested FieldSelector for the field, or an unrestricted selector if none exists + */ + public FieldSelector nested(String field) { + if (nested == null || !nested.containsKey(field)) { + return new FieldSelector(null, null); // no restriction on nested level either + } + return nested.get(field); + } + + /** + * Finds the index of the next comma at the current nesting depth, or the end of the string. + *

+ * This method tracks parenthesis depth to avoid splitting on commas that are inside + * nested field specifications. Only commas at depth 0 (not inside any parentheses) + * are considered as field separators. + *

+ * + * @param spec the specification string to search + * @param from the starting index for the search + * @return the index of the next comma at depth 0, or the length of the string if none found + */ + private static int nextCommaOrEnd(String spec, int from) { + int depth = 0; + for (int i = from; i < spec.length(); i++) { + char c = spec.charAt(i); + if (c == '(') depth++; + else if (c == ')') depth--; + else if (c == ',' && depth == 0) return i; + } + return spec.length(); + } + + /** + * Finds the index of the closing parenthesis that matches the opening parenthesis at the specified position. + *

+ * This method correctly handles nested parentheses by tracking the depth level and returning + * the index of the closing parenthesis that brings the depth back to 0. + *

+ * + * @param spec the specification string to search + * @param openIdx the index of the opening parenthesis + * @return the index of the matching closing parenthesis + * @throws IllegalArgumentException if no matching closing parenthesis is found + */ + private static int findMatchingParen(String spec, int openIdx) { + int depth = 0; + for (int i = openIdx; i < spec.length(); i++) { + char c = spec.charAt(i); + if (c == '(') depth++; + else if (c == ')') { + depth--; + if (depth == 0) return i; + } + } + throw new IllegalArgumentException("Unmatched '(' in fields selector: " + spec); + } +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/serialization/FieldSelectorHolder.java b/application-engine/src/main/java/com/netgrif/application/engine/serialization/FieldSelectorHolder.java new file mode 100644 index 00000000000..fd21df71241 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/serialization/FieldSelectorHolder.java @@ -0,0 +1,36 @@ +package com.netgrif.application.engine.serialization; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +/** + * Request-scoped holder component for managing {@link FieldSelector} instances during HTTP request processing. + *

+ * This component is used in conjunction with {@link FieldSelectorInterceptor} to parse and store field selection + * criteria from HTTP request parameters (typically the "fields" query parameter). The held {@link FieldSelector} + * is then used by custom serializers like {@link LocalizedEventOutcomeSerializer} to control which fields + * are included in JSON response bodies. + *

+ *

+ * Being request-scoped ensures that each HTTP request has its own isolated instance of this holder, + * preventing thread-safety issues in concurrent request processing. + *

+ * + * @see FieldSelector + * @see FieldSelectorInterceptor + * @see LocalizedEventOutcomeSerializer + */ +@Setter +@Getter +@Component +@RequestScope +public class FieldSelectorHolder { + + /** + * The field selector that determines which fields should be included during serialization. + * Initialized with a default selector that includes all fields (parsed from null). + */ + private FieldSelector selector = FieldSelector.parse(null); +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/serialization/FieldSelectorInterceptor.java b/application-engine/src/main/java/com/netgrif/application/engine/serialization/FieldSelectorInterceptor.java new file mode 100644 index 00000000000..e3e53292f2d --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/serialization/FieldSelectorInterceptor.java @@ -0,0 +1,24 @@ +package com.netgrif.application.engine.serialization; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class FieldSelectorInterceptor implements HandlerInterceptor { + + private final FieldSelectorHolder holder; + + public FieldSelectorInterceptor(FieldSelectorHolder holder) { + this.holder = holder; + } + + @Override + public boolean preHandle(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) { + String fields = request.getParameter("fields"); + holder.setSelector(FieldSelector.parse(fields)); + return true; + } +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/serialization/LocalizedEventOutcomeSerializer.java b/application-engine/src/main/java/com/netgrif/application/engine/serialization/LocalizedEventOutcomeSerializer.java new file mode 100644 index 00000000000..4f544a24c63 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/serialization/LocalizedEventOutcomeSerializer.java @@ -0,0 +1,122 @@ +package com.netgrif.application.engine.serialization; + +import com.netgrif.application.engine.workflow.web.responsebodies.eventoutcomes.LocalisedGetDataEventOutcome; +import com.netgrif.application.engine.workflow.web.responsebodies.eventoutcomes.LocalisedGetDataGroupsEventOutcome; +import com.netgrif.application.engine.workflow.web.responsebodies.eventoutcomes.LocalisedSetDataEventOutcome; +import com.netgrif.application.engine.workflow.web.responsebodies.eventoutcomes.base.LocalisedCaseEventOutcome; +import com.netgrif.application.engine.workflow.web.responsebodies.eventoutcomes.base.LocalisedEventOutcome; +import com.netgrif.application.engine.workflow.web.responsebodies.eventoutcomes.base.LocalisedPetriNetEventOutcome; +import com.netgrif.application.engine.workflow.web.responsebodies.eventoutcomes.base.LocalisedTaskEventOutcome; +import org.springframework.boot.jackson.JacksonComponent; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; + +import java.io.IOException; + +/** + * Custom JSON serializer for {@link LocalisedEventOutcome} objects that provides selective field serialization + * based on a {@link FieldSelector} configuration. + *

+ * This serializer handles various types of localized event outcomes including: + *

    + *
  • {@link LocalisedPetriNetEventOutcome} - outcomes related to Petri nets
  • + *
  • {@link LocalisedCaseEventOutcome} - outcomes related to cases
  • + *
  • {@link LocalisedTaskEventOutcome} - outcomes related to tasks
  • + *
  • {@link LocalisedGetDataEventOutcome} - outcomes for data retrieval operations
  • + *
  • {@link LocalisedGetDataGroupsEventOutcome} - outcomes for data groups retrieval
  • + *
  • {@link LocalisedSetDataEventOutcome} - outcomes for data modification operations
  • + *
+ * The serializer uses a {@link DynamicFieldSerializer} to handle nested object serialization with field selection support. + *

+ * + * @see LocalisedEventOutcome + * @see FieldSelector + * @see DynamicFieldSerializer + */ +@JacksonComponent(type = LocalisedEventOutcome.class) +public class LocalizedEventOutcomeSerializer extends ValueSerializer { + + /** + * Holder containing the {@link FieldSelector} that determines which fields should be included + * in the serialized JSON output. + */ + private final FieldSelectorHolder selectorHolder; + + /** + * Serializer used for dynamic field serialization of nested objects with field selection support. + */ + private final DynamicFieldSerializer dynamicSerializer; + + /** + * Constructs a new LocalizedEventOutcomeSerializer with the specified field selector holder. + * + * @param selectorHolder the holder containing the field selector configuration for controlling + * which fields are included in the serialized output + */ + public LocalizedEventOutcomeSerializer(FieldSelectorHolder selectorHolder) { + this.selectorHolder = selectorHolder; + this.dynamicSerializer = new DynamicFieldSerializer(selectorHolder); + } + + /** + * Serializes a {@link LocalisedEventOutcome} object to JSON format with selective field inclusion + * based on the configured {@link FieldSelector}. + *

+ * The method handles serialization of common fields (message, frontActions, outcomes) and type-specific + * fields based on the actual runtime type of the outcome object. Nested objects are serialized using + * the {@link DynamicFieldSerializer} with their corresponding nested field selectors. + *

+ * + * @param outcome the localized event outcome object to serialize + * @param gen the JSON generator used to write JSON content + * @param provider the serializer provider for accessing additional serializers + * @throws IOException if an I/O error occurs during serialization + */ + @Override + public void serialize(LocalisedEventOutcome outcome, JsonGenerator gen, SerializationContext context) { + gen.writeStartObject(); + FieldSelector selector = selectorHolder.getSelector(); + + if (selector.includes("message") && outcome.getMessage() != null) { + gen.writePOJOProperty("message", outcome.getMessage()); + } + if (selector.includes("frontActions") && outcome.getFrontActions() != null) { + gen.writePOJOProperty("frontActions", outcome.getFrontActions()); + } + if (selector.includes("outcomes") + && outcome.getOutcomes() != null) { + gen.writeArrayPropertyStart("outcomes"); + for (LocalisedEventOutcome child : outcome.getOutcomes()) { + context.findValueSerializer(child.getClass()).serialize(child, gen, context); + } + gen.writeEndArray(); + } + if (outcome instanceof LocalisedPetriNetEventOutcome specificOutcome && selector.includes("net") && specificOutcome.getNet() != null) { + gen.writeName("net"); + dynamicSerializer.serializeWithSelector(specificOutcome.getNet(), gen, context, selector.nested("net")); + } + if (outcome instanceof LocalisedCaseEventOutcome specificOutcome && selector.includes("case") && specificOutcome.getaCase() != null) { + gen.writeName("aCase"); + dynamicSerializer.serializeWithSelector(specificOutcome.getaCase(), gen, context, selector.nested("case")); + } + if (outcome instanceof LocalisedTaskEventOutcome specificOutcome && selector.includes("task") && specificOutcome.getTask() != null) { + gen.writeName("task"); + dynamicSerializer.serializeWithSelector(specificOutcome.getTask(), gen, context, selector.nested("task")); + } + if (outcome instanceof LocalisedGetDataEventOutcome specificOutcome && selector.includes("data") && specificOutcome.getData() != null) { + gen.writeName("data"); + dynamicSerializer.serializeWithSelector(specificOutcome.getData(), gen, context, selector.nested("data")); + } + if (outcome instanceof LocalisedGetDataGroupsEventOutcome specificOutcome && selector.includes("data") && specificOutcome.getData() != null) { + gen.writeName("data"); + dynamicSerializer.serializeWithSelector(specificOutcome.getData(), gen, context, selector.nested("data")); + } + if (outcome instanceof LocalisedSetDataEventOutcome specificOutcome && selector.includes("changedFields") && specificOutcome.getChangedFields() != null) { + gen.writeName("changedFields"); + dynamicSerializer.serializeWithSelector(specificOutcome.getChangedFields(), gen, context, selector.nested("changedFields")); + } + + gen.writeEndObject(); + } +} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/serialization/ObjectMapperConfiguration.java b/application-engine/src/main/java/com/netgrif/application/engine/serialization/ObjectMapperConfiguration.java new file mode 100644 index 00000000000..f311a8205c1 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/serialization/ObjectMapperConfiguration.java @@ -0,0 +1,21 @@ +package com.netgrif.application.engine.serialization; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.netgrif.application.engine.workflow.web.responsebodies.eventoutcomes.base.LocalisedEventOutcome; +import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.module.SimpleModule; + +@Configuration +public class ObjectMapperConfiguration { + + @Bean + public JsonMapperBuilderCustomizer jsonCustomizer(FieldSelectorHolder holder) { + SimpleModule module = new SimpleModule(); + module.addSerializer(LocalisedEventOutcome.class, new LocalizedEventOutcomeSerializer(holder)); + return builder -> builder + .changeDefaultPropertyInclusion(include -> include.withValueInclusion(JsonInclude.Include.NON_NULL)) + .findAndAddModules(); + } +}