Skip to content

Commit a20e4b7

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 4ad3287 commit a20e4b7

20 files changed

+895
-56
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: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema;
2121
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.CheckboxField;
2222
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.CheckboxGroupField;
23+
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.DynamicOptionsAttribute;
2324
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.Field;
2425
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.InputField;
2526
import io.gravitee.apim.core.subscription_form.model.SubscriptionFormSchema.MinLengthAttribute;
@@ -61,17 +62,27 @@ private static List<Constraint> constraintsFor(Field field) {
6162
}
6263

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

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-
}
72+
private static void addRequiredConstraint(Field field, List<Constraint> out) {
73+
if (!field.required()) {
74+
return;
7375
}
76+
if (field instanceof CheckboxField) {
77+
out.add(new Constraint.MustBeTrue());
78+
} else if (field instanceof CheckboxGroupField) {
79+
out.add(new Constraint.NonEmptySelection());
80+
} else {
81+
out.add(new Constraint.Required());
82+
}
83+
}
7484

85+
private static void addLengthConstraints(Field field, List<Constraint> out) {
7586
if (field instanceof MinLengthAttribute min && min.minLength() != null) {
7687
out.add(new Constraint.MinLength(min.minLength()));
7788
}
@@ -82,19 +93,28 @@ private static List<Constraint> constraintsFor(Field field) {
8293
textarea.maxLength() != null ? Constraint.MaxLength.forTextarea(textarea.maxLength()) : Constraint.MaxLength.forTextarea()
8394
);
8495
}
96+
}
97+
98+
private static void addPatternConstraint(Field field, List<Constraint> out) {
8599
if (field instanceof PatternAttribute pat && pat.pattern() != null) {
86100
out.add(new Constraint.MatchesPattern(pat.pattern()));
87101
}
102+
}
88103

89-
if (field instanceof OptionsAttribute opt && hasOptions(opt.options())) {
104+
private static void addOptionsConstraint(Field field, List<Constraint> out) {
105+
if (field instanceof DynamicOptionsAttribute dyn && dyn.dynamicOptions() != null) {
106+
if (field instanceof CheckboxGroupField) {
107+
out.add(new Constraint.DynamicEachOf(dyn.dynamicOptions().expression(), dyn.dynamicOptions().fallback()));
108+
} else {
109+
out.add(new Constraint.DynamicOneOf(dyn.dynamicOptions().expression(), dyn.dynamicOptions().fallback()));
110+
}
111+
} else if (field instanceof OptionsAttribute opt && hasOptions(opt.options())) {
90112
if (field instanceof CheckboxGroupField) {
91113
out.add(new Constraint.EachOf(opt.options()));
92114
} else {
93115
out.add(new Constraint.OneOf(opt.options()));
94116
}
95117
}
96-
97-
return out;
98118
}
99119

100120
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: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
@JsonSubTypes.Type(value = Constraint.MatchesPattern.class, name = "matchesPattern"),
5151
@JsonSubTypes.Type(value = Constraint.OneOf.class, name = "oneOf"),
5252
@JsonSubTypes.Type(value = Constraint.EachOf.class, name = "eachOf"),
53+
@JsonSubTypes.Type(value = Constraint.DynamicOneOf.class, name = "dynamicOneOf"),
54+
@JsonSubTypes.Type(value = Constraint.DynamicEachOf.class, name = "dynamicEachOf"),
5355
}
5456
)
5557
public sealed interface Constraint
@@ -62,7 +64,9 @@ public sealed interface Constraint
6264
Constraint.MaxLength,
6365
Constraint.MatchesPattern,
6466
Constraint.OneOf,
65-
Constraint.EachOf {
67+
Constraint.EachOf,
68+
Constraint.DynamicOneOf,
69+
Constraint.DynamicEachOf {
6670
/**
6771
* Whether the submitted value satisfies this constraint. The value is already trimmed; an empty string means absent.
6872
*/
@@ -248,6 +252,42 @@ public String formatErrorMessage(String fieldKey, String value) {
248252
}
249253
}
250254

255+
/**
256+
* Placeholder for a single-value option constraint whose allowed set is resolved at runtime from an EL expression.
257+
*
258+
* <p>This constraint is stored in the database and must be pre-resolved to a {@link OneOf} constraint
259+
* (using the expression against the target API's metadata) before being passed to the validator.
260+
* Its {@link #check(String)} throws {@link IllegalStateException} — it must never be evaluated directly.</p>
261+
*/
262+
record DynamicOneOf(String expression, List<String> fallback) implements Constraint {
263+
@Override
264+
public boolean check(String value) {
265+
throw new IllegalStateException("DynamicOneOf must be resolved before validation — call resolveConstraints() first");
266+
}
267+
268+
@Override
269+
public String formatErrorMessage(String fieldKey, String value) {
270+
throw new IllegalStateException("DynamicOneOf must be resolved before validation — call resolveConstraints() first");
271+
}
272+
}
273+
274+
/**
275+
* Placeholder for a multi-value option constraint whose allowed set is resolved at runtime from an EL expression.
276+
*
277+
* <p>Same pre-resolution semantics as {@link DynamicOneOf}, but resolves to an {@link EachOf} constraint.</p>
278+
*/
279+
record DynamicEachOf(String expression, List<String> fallback) implements Constraint {
280+
@Override
281+
public boolean check(String value) {
282+
throw new IllegalStateException("DynamicEachOf must be resolved before validation — call resolveConstraints() first");
283+
}
284+
285+
@Override
286+
public String formatErrorMessage(String fieldKey, String value) {
287+
throw new IllegalStateException("DynamicEachOf must be resolved before validation — call resolveConstraints() first");
288+
}
289+
}
290+
251291
private static Stream<String> splitCsv(String value) {
252292
return Arrays.stream(value.split(","))
253293
.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: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ public interface OptionsAttribute {
5858
List<String> options();
5959
}
6060

61+
public interface DynamicOptionsAttribute {
62+
DynamicOptions dynamicOptions();
63+
}
64+
65+
/**
66+
* Holds an EL expression and its fallback option list for option-bearing fields.
67+
*
68+
* <p>GMD syntax: {@code options="${expression}:fallback1,fallback2"}</p>
69+
* <p>When present on a field, the expression is resolved at retrieval time against the
70+
* target API's metadata. If resolution fails, {@code fallback} is used instead.</p>
71+
*/
72+
public record DynamicOptions(String expression, List<String> fallback) {}
73+
6174
/**
6275
* Marker interface for all field types in a subscription form schema.
6376
* Each implementation captures only the validation attributes relevant to its component type.
@@ -82,16 +95,23 @@ public record InputField(
8295
public record TextareaField(String fieldKey, boolean required, String readonlyValue, Integer minLength, Integer maxLength) implements
8396
Field, ReadOnlyValueAttribute, MinLengthAttribute, MaxLengthAttribute {}
8497

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 {}
98+
/** Single-value dropdown — supports required, static options, or dynamic EL options. No readonly (not supported by the GMD component). */
99+
public record SelectField(String fieldKey, boolean required, List<String> options, DynamicOptions dynamicOptions) implements
100+
Field, OptionsAttribute, DynamicOptionsAttribute {}
87101

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 {}
102+
/** Single-value radio group — supports required, readonly, static options, or dynamic EL options. */
103+
public record RadioField(
104+
String fieldKey,
105+
boolean required,
106+
String readonlyValue,
107+
List<String> options,
108+
DynamicOptions dynamicOptions
109+
) implements Field, ReadOnlyValueAttribute, OptionsAttribute, DynamicOptionsAttribute {}
91110

92111
/** Boolean checkbox — supports required, readonly. Value is "true"/"false". */
93112
public record CheckboxField(String fieldKey, boolean required, String readonlyValue) implements Field, ReadOnlyValueAttribute {}
94113

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 {}
114+
/** Multi-value checkbox group — supports required, static options, or dynamic EL options. No readonly (not supported by the GMD component). */
115+
public record CheckboxGroupField(String fieldKey, boolean required, List<String> options, DynamicOptions dynamicOptions) implements
116+
Field, OptionsAttribute, DynamicOptionsAttribute {}
97117
}

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/apim/core/subscription_form/use_case/GetSubscriptionFormForEnvironmentUseCase.java

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,22 @@
1616
package io.gravitee.apim.core.subscription_form.use_case;
1717

1818
import io.gravitee.apim.core.UseCase;
19+
import io.gravitee.apim.core.subscription_form.domain_service.SubscriptionFormElResolverDomainService;
20+
import io.gravitee.apim.core.subscription_form.domain_service.SubscriptionFormSchemaGenerator;
1921
import io.gravitee.apim.core.subscription_form.exception.SubscriptionFormNotFoundException;
2022
import io.gravitee.apim.core.subscription_form.model.SubscriptionForm;
2123
import io.gravitee.apim.core.subscription_form.query_service.SubscriptionFormQueryService;
24+
import java.util.List;
25+
import java.util.Map;
26+
import lombok.Builder;
2227
import lombok.RequiredArgsConstructor;
2328

2429
/**
25-
* Use case for getting the subscription form for an environment (the default form for that environment).
30+
* Use case for getting the subscription form for an environment.
31+
*
32+
* <p>When {@link Input#apiId()} is non-null, EL expressions in option-bearing fields are resolved
33+
* against the target API's metadata and returned in {@link Output#resolvedOptions()}.
34+
* When null (e.g. Console Form Builder), only environment-level metadata is used for resolution.</p>
2635
*
2736
* @author Gravitee.io Team
2837
*/
@@ -31,6 +40,8 @@
3140
public class GetSubscriptionFormForEnvironmentUseCase {
3241

3342
private final SubscriptionFormQueryService subscriptionFormQueryService;
43+
private final SubscriptionFormSchemaGenerator schemaGenerator;
44+
private final SubscriptionFormElResolverDomainService elResolver;
3445

3546
public Output execute(Input input) {
3647
var subscriptionForm = subscriptionFormQueryService
@@ -41,10 +52,25 @@ public Output execute(Input input) {
4152
throw new SubscriptionFormNotFoundException(input.environmentId());
4253
}
4354

44-
return new Output(subscriptionForm);
55+
var schema = schemaGenerator.generate(subscriptionForm.getGmdContent());
56+
var resolvedOptions = input.apiId() != null
57+
? elResolver.resolveSchemaOptions(schema, input.environmentId(), input.apiId())
58+
: elResolver.resolveSchemaOptions(schema);
59+
60+
return new Output(subscriptionForm, resolvedOptions);
4561
}
4662

47-
public record Input(String environmentId, boolean onlyEnabled) {}
63+
@Builder
64+
public record Input(String environmentId, boolean onlyEnabled, String apiId) {
65+
/**
66+
* Backward-compatible constructor for callers that don't provide an API context.
67+
* TODO: remove once the resource layer is wired (APIM-12990 resource PR).
68+
*/
69+
@Deprecated
70+
public Input(String environmentId, boolean onlyEnabled) {
71+
this(environmentId, onlyEnabled, null);
72+
}
73+
}
4874

49-
public record Output(SubscriptionForm subscriptionForm) {}
75+
public record Output(SubscriptionForm subscriptionForm, Map<String, List<String>> resolvedOptions) {}
5076
}

gravitee-apim-rest-api/gravitee-apim-rest-api-service/src/main/java/io/gravitee/apim/core/subscription_form/use_case/UpdateSubscriptionFormUseCase.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@ public Output execute(Input input) {
6262
);
6363

6464
var gmd = GraviteeMarkdown.of(input.gmdContent());
65-
var schema = schemaGenerator.generate(gmd);
65+
SubscriptionFormSchema schema;
66+
try {
67+
schema = schemaGenerator.generate(gmd);
68+
} catch (IllegalArgumentException e) {
69+
throw new SubscriptionFormValidationException(List.of(e.getMessage()));
70+
}
6671
validateFieldCount(schema);
6772
var constraints = SubscriptionFormConstraintsFactory.fromSchema(schema);
6873
existingForm.update(gmd, constraints);

0 commit comments

Comments
 (0)