Skip to content

Commit 7e12ef3

Browse files
feat: add EL resolution for dynamic subscription form options - domain layer
Introduces SubscriptionFormElResolverDomainService with EL-based resolution of dynamic schema options and constraints, backed by API/environment metadata. Extends Constraint and SubscriptionFormSchema models with dynamic variants. Adds ApiMetadataQueryService#findApiMetadata and introduces no-context overloads that return fallbacks directly when no API context is available.
1 parent 6b49fe0 commit 7e12ef3

20 files changed

+949
-66
lines changed

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/apim/core/api/query_service/ApiMetadataQueryService.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@
1616
package io.gravitee.apim.core.api.query_service;
1717

1818
import io.gravitee.apim.core.api.model.ApiMetadata;
19-
import io.gravitee.rest.api.service.common.ExecutionContext;
2019
import java.util.Map;
2120

2221
public interface ApiMetadataQueryService {
2322
/**
24-
* Find all metadata with their default value for an API.
23+
* Find all metadata for an API, merging environment-level defaults with API-level overrides.
2524
* @param environmentId The environment id.
2625
* @param apiId The API id.
27-
* @return A map of metadata key and metadata.
26+
* @return A map of metadata key to metadata (API value takes precedence over env default).
2827
*/
2928
Map<String, ApiMetadata> findApiMetadata(String environmentId, String apiId);
3029
}

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/apim/core/subscription_form/domain_service/SubscriptionFormConstraintsFactory.java

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@
1818
import io.gravitee.apim.core.subscription_form.model.Constraint;
1919
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormFieldConstraints;
2020
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema;
21-
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.CheckboxField;
22-
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.CheckboxGroupField;
2321
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.Field;
2422
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.InputField;
2523
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.MinLengthAttribute;
26-
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.OptionsAttribute;
24+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.MultiValueOptionsField;
2725
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.PatternAttribute;
2826
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.ReadOnlyValueAttribute;
27+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.RequiredSelectionAttribute;
28+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.RequiredTrueAttribute;
29+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.RequiredValueAttribute;
30+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.SingleValueOptionsField;
2931
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.TextareaField;
3032
import java.util.ArrayList;
3133
import java.util.LinkedHashMap;
@@ -61,17 +63,27 @@ private static List<Constraint> constraintsFor(Field field) {
6163
}
6264

6365
var out = new ArrayList<Constraint>();
66+
addRequiredConstraint(field, out);
67+
addLengthConstraints(field, out);
68+
addPatternConstraint(field, out);
69+
addOptionsConstraint(field, out);
70+
return out;
71+
}
6472

65-
if (field.required()) {
66-
if (field instanceof CheckboxField) {
67-
out.add(new Constraint.MustBeTrue());
68-
} else if (field instanceof CheckboxGroupField) {
69-
out.add(new Constraint.NonEmptySelection());
70-
} else {
71-
out.add(new Constraint.Required());
72-
}
73+
private static void addRequiredConstraint(Field field, List<Constraint> out) {
74+
if (!field.required()) {
75+
return;
7376
}
77+
if (field instanceof RequiredTrueAttribute) {
78+
out.add(new Constraint.MustBeTrue());
79+
} else if (field instanceof RequiredSelectionAttribute) {
80+
out.add(new Constraint.NonEmptySelection());
81+
} else if (field instanceof RequiredValueAttribute) {
82+
out.add(new Constraint.Required());
83+
}
84+
}
7485

86+
private static void addLengthConstraints(Field field, List<Constraint> out) {
7587
if (field instanceof MinLengthAttribute min && min.minLength() != null) {
7688
out.add(new Constraint.MinLength(min.minLength()));
7789
}
@@ -82,19 +94,42 @@ private static List<Constraint> constraintsFor(Field field) {
8294
textarea.maxLength() != null ? Constraint.MaxLength.forTextarea(textarea.maxLength()) : Constraint.MaxLength.forTextarea()
8395
);
8496
}
97+
}
98+
99+
private static void addPatternConstraint(Field field, List<Constraint> out) {
85100
if (field instanceof PatternAttribute pat && pat.pattern() != null) {
86101
out.add(new Constraint.MatchesPattern(pat.pattern()));
87102
}
103+
}
88104

89-
if (field instanceof OptionsAttribute opt && hasOptions(opt.options())) {
90-
if (field instanceof CheckboxGroupField) {
91-
out.add(new Constraint.EachOf(opt.options()));
92-
} else {
93-
out.add(new Constraint.OneOf(opt.options()));
94-
}
105+
private static void addOptionsConstraint(Field field, List<Constraint> out) {
106+
if (field instanceof SingleValueOptionsField single) {
107+
addSingleValueOptionsConstraint(single, out);
108+
} else if (field instanceof MultiValueOptionsField multi) {
109+
addMultiValueOptionsConstraint(multi, out);
95110
}
111+
}
96112

97-
return out;
113+
private static void addSingleValueOptionsConstraint(SingleValueOptionsField field, List<Constraint> out) {
114+
var dynamic = field.dynamicOptions();
115+
if (dynamic != null) {
116+
out.add(new Constraint.DynamicOneOf(dynamic.expression(), dynamic.fallback()));
117+
return;
118+
}
119+
if (hasOptions(field.options())) {
120+
out.add(new Constraint.OneOf(field.options()));
121+
}
122+
}
123+
124+
private static void addMultiValueOptionsConstraint(MultiValueOptionsField field, List<Constraint> out) {
125+
var dynamic = field.dynamicOptions();
126+
if (dynamic != null) {
127+
out.add(new Constraint.DynamicEachOf(dynamic.expression(), dynamic.fallback()));
128+
return;
129+
}
130+
if (hasOptions(field.options())) {
131+
out.add(new Constraint.EachOf(field.options()));
132+
}
98133
}
99134

100135
private static boolean hasOptions(List<String> options) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright © 2015 The Gravitee team (http://gravitee.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.gravitee.apim.core.subscription_form.domain_service;
17+
18+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormFieldConstraints;
19+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema;
20+
import jakarta.annotation.Nonnull;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
/**
25+
* Resolves EL expressions embedded in subscription form option fields.
26+
*
27+
* <p>Used in two contexts:</p>
28+
* <ul>
29+
* <li><b>Form retrieval</b> — resolves {@link SubscriptionFormSchema.DynamicOptions} expressions for
30+
* option-bearing fields and returns a {@code fieldKey → resolved options} map to the frontend.</li>
31+
* <li><b>Subscription validation</b> — replaces {@link io.gravitee.apim.core.subscription_form.model.Constraint.DynamicOneOf}
32+
* and {@link io.gravitee.apim.core.subscription_form.model.Constraint.DynamicEachOf} constraints with
33+
* their resolved static equivalents before the validator runs.</li>
34+
* </ul>
35+
*
36+
* <p>Each method has two variants: one that accepts an API context (environment + API id) for full metadata
37+
* resolution, and one without — for situations where no API context is available (e.g. Console Form Builder).
38+
* The no-context variants use the configured fallback values directly.</p>
39+
*
40+
* @author Gravitee.io Team
41+
*/
42+
public interface SubscriptionFormElResolverDomainService {
43+
/**
44+
* Resolves EL expressions for all dynamic-option fields in the schema against API + environment metadata.
45+
*
46+
* @param schema the subscription form schema
47+
* @param environmentId the environment identifier
48+
* @param apiId the API identifier
49+
* @return a map of {@code fieldKey → effective option list} for every field that has dynamic options
50+
*/
51+
Map<String, List<String>> resolveSchemaOptions(SubscriptionFormSchema schema, String environmentId, String apiId);
52+
53+
/**
54+
* No-context variant — returns an empty map; callers should display the fallback values from the schema.
55+
*/
56+
Map<String, List<String>> resolveSchemaOptions(SubscriptionFormSchema schema);
57+
58+
/**
59+
* Pre-resolves {@link io.gravitee.apim.core.subscription_form.model.Constraint.DynamicOneOf} and
60+
* {@link io.gravitee.apim.core.subscription_form.model.Constraint.DynamicEachOf} constraints to their
61+
* static equivalents using API + environment metadata.
62+
*
63+
* @param constraints the stored constraints (may contain dynamic variants)
64+
* @param environmentId the environment identifier
65+
* @param apiId the API identifier
66+
* @return a new {@link SubscriptionFormFieldConstraints} with all dynamic constraints resolved
67+
*/
68+
SubscriptionFormFieldConstraints resolveConstraints(
69+
@Nonnull SubscriptionFormFieldConstraints constraints,
70+
String environmentId,
71+
String apiId
72+
);
73+
74+
/**
75+
* No-context variant — resolves dynamic constraints to their static equivalents using configured fallback values.
76+
*/
77+
SubscriptionFormFieldConstraints resolveConstraints(@Nonnull SubscriptionFormFieldConstraints constraints);
78+
}

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/apim/core/subscription_form/model/Constraint.java

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.HashSet;
2222
import java.util.List;
2323
import java.util.Set;
24+
import java.util.function.Function;
2425
import java.util.regex.PatternSyntaxException;
2526
import java.util.stream.Stream;
2627

@@ -50,6 +51,8 @@
5051
@JsonSubTypes.Type(value = Constraint.MatchesPattern.class, name = "matchesPattern"),
5152
@JsonSubTypes.Type(value = Constraint.OneOf.class, name = "oneOf"),
5253
@JsonSubTypes.Type(value = Constraint.EachOf.class, name = "eachOf"),
54+
@JsonSubTypes.Type(value = Constraint.DynamicOneOf.class, name = "dynamicOneOf"),
55+
@JsonSubTypes.Type(value = Constraint.DynamicEachOf.class, name = "dynamicEachOf"),
5356
}
5457
)
5558
public sealed interface Constraint
@@ -62,7 +65,8 @@ public sealed interface Constraint
6265
Constraint.MaxLength,
6366
Constraint.MatchesPattern,
6467
Constraint.OneOf,
65-
Constraint.EachOf {
68+
Constraint.EachOf,
69+
Constraint.ResolvableOptions {
6670
/**
6771
* Whether the submitted value satisfies this constraint. The value is already trimmed; an empty string means absent.
6872
*/
@@ -248,6 +252,60 @@ public String formatErrorMessage(String fieldKey, String value) {
248252
}
249253
}
250254

255+
/**
256+
* Contract for option constraints that are resolved at runtime.
257+
*
258+
* <p>Implementations store an expression and fallback options. They must be resolved to static variants via
259+
* {@link #resolve(Function)} before validation.</p>
260+
*/
261+
sealed interface ResolvableOptions extends Constraint permits DynamicOneOf, DynamicEachOf {
262+
String expression();
263+
264+
List<String> fallback();
265+
266+
Constraint resolve(Function<String, List<String>> resolver);
267+
268+
@Override
269+
default boolean check(String value) {
270+
throw new IllegalStateException(
271+
getClass().getSimpleName() + " must be resolved before validation — call resolveConstraints() first"
272+
);
273+
}
274+
275+
@Override
276+
default String formatErrorMessage(String fieldKey, String value) {
277+
throw new IllegalStateException(
278+
getClass().getSimpleName() + " must be resolved before validation — call resolveConstraints() first"
279+
);
280+
}
281+
}
282+
283+
/**
284+
* Placeholder for a single-value option constraint whose allowed set is resolved at runtime from an EL expression.
285+
*
286+
* <p>This constraint is stored in the database and must be pre-resolved to a {@link OneOf} constraint
287+
* (using the expression against the target API's metadata) before being passed to the validator.
288+
* Its {@link #check(String)} throws {@link IllegalStateException} — it must never be evaluated directly.</p>
289+
*/
290+
record DynamicOneOf(String expression, List<String> fallback) implements ResolvableOptions {
291+
@Override
292+
public Constraint resolve(Function<String, List<String>> resolver) {
293+
return new OneOf(resolver.apply(expression));
294+
}
295+
}
296+
297+
/**
298+
* Placeholder for a multi-value option constraint whose allowed set is resolved at runtime from an EL expression.
299+
*
300+
* <p>Same pre-resolution semantics as {@link DynamicOneOf}, but resolves to an {@link EachOf} constraint.</p>
301+
*/
302+
record DynamicEachOf(String expression, List<String> fallback) implements ResolvableOptions {
303+
@Override
304+
public Constraint resolve(Function<String, List<String>> resolver) {
305+
return new EachOf(resolver.apply(expression));
306+
}
307+
}
308+
251309
private static Stream<String> splitCsv(String value) {
252310
return Arrays.stream(value.split(","))
253311
.map(String::trim)

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/apim/core/subscription_form/model/SubscriptionFormSchema.java

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ public interface RequiredAttribute {
3838
boolean required();
3939
}
4040

41+
public interface RequiredValueAttribute extends RequiredAttribute {}
42+
43+
public interface RequiredTrueAttribute extends RequiredAttribute {}
44+
45+
public interface RequiredSelectionAttribute extends RequiredAttribute {}
46+
4147
public interface ReadOnlyValueAttribute {
4248
String readonlyValue();
4349
}
@@ -58,6 +64,23 @@ public interface OptionsAttribute {
5864
List<String> options();
5965
}
6066

67+
public interface DynamicOptionsAttribute {
68+
DynamicOptions dynamicOptions();
69+
}
70+
71+
public interface SingleValueOptionsField extends OptionsAttribute, DynamicOptionsAttribute {}
72+
73+
public interface MultiValueOptionsField extends OptionsAttribute, DynamicOptionsAttribute {}
74+
75+
/**
76+
* Holds an EL expression and its fallback option list for option-bearing fields.
77+
*
78+
* <p>GMD syntax: {@code options="{#expression}:fallback1,fallback2"}</p>
79+
* <p>When present on a field, the expression is resolved at retrieval time against the
80+
* target API's metadata. If resolution fails, {@code fallback} is used instead.</p>
81+
*/
82+
public record DynamicOptions(String expression, List<String> fallback) {}
83+
6184
/**
6285
* Marker interface for all field types in a subscription form schema.
6386
* Each implementation captures only the validation attributes relevant to its component type.
@@ -76,22 +99,30 @@ public record InputField(
7699
Integer minLength,
77100
Integer maxLength,
78101
String pattern
79-
) implements Field, ReadOnlyValueAttribute, MinLengthAttribute, MaxLengthAttribute, PatternAttribute {}
102+
) implements Field, RequiredValueAttribute, ReadOnlyValueAttribute, MinLengthAttribute, MaxLengthAttribute, PatternAttribute {}
80103

81104
/** Multi-line textarea — supports required, readonly, minLength, maxLength. */
82105
public record TextareaField(String fieldKey, boolean required, String readonlyValue, Integer minLength, Integer maxLength) implements
83-
Field, ReadOnlyValueAttribute, MinLengthAttribute, MaxLengthAttribute {}
106+
Field, RequiredValueAttribute, ReadOnlyValueAttribute, MinLengthAttribute, MaxLengthAttribute {}
84107

85-
/** Single-value dropdown — supports required, options. No readonly (not supported by the GMD component). */
86-
public record SelectField(String fieldKey, boolean required, List<String> options) implements Field, OptionsAttribute {}
108+
/** Single-value dropdown — supports required, static options, or dynamic EL options. No readonly (not supported by the GMD component). */
109+
public record SelectField(String fieldKey, boolean required, List<String> options, DynamicOptions dynamicOptions) implements
110+
Field, RequiredValueAttribute, SingleValueOptionsField {}
87111

88-
/** Single-value radio group — supports required, readonly, options. */
89-
public record RadioField(String fieldKey, boolean required, String readonlyValue, List<String> options) implements
90-
Field, ReadOnlyValueAttribute, OptionsAttribute {}
112+
/** Single-value radio group — supports required, readonly, static options, or dynamic EL options. */
113+
public record RadioField(
114+
String fieldKey,
115+
boolean required,
116+
String readonlyValue,
117+
List<String> options,
118+
DynamicOptions dynamicOptions
119+
) implements Field, RequiredValueAttribute, ReadOnlyValueAttribute, SingleValueOptionsField {}
91120

92121
/** Boolean checkbox — supports required, readonly. Value is "true"/"false". */
93-
public record CheckboxField(String fieldKey, boolean required, String readonlyValue) implements Field, ReadOnlyValueAttribute {}
122+
public record CheckboxField(String fieldKey, boolean required, String readonlyValue) implements
123+
Field, RequiredTrueAttribute, ReadOnlyValueAttribute {}
94124

95-
/** Multi-value checkbox group — supports required, options. No readonly (not supported by the GMD component). */
96-
public record CheckboxGroupField(String fieldKey, boolean required, List<String> options) implements Field, OptionsAttribute {}
125+
/** Multi-value checkbox group — supports required, static options, or dynamic EL options. No readonly (not supported by the GMD component). */
126+
public record CheckboxGroupField(String fieldKey, boolean required, List<String> options, DynamicOptions dynamicOptions) implements
127+
Field, RequiredSelectionAttribute, MultiValueOptionsField {}
97128
}

0 commit comments

Comments
 (0)