• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

FIWARE / contract-management / #100

02 Sep 2026 02:30PM UTC coverage: 3.796% (+1.1%) from 2.677%
#100

Pull #27

web-flow
Update til.yaml
Pull Request #27: Topic/spec composition hardening

326 of 348 new or added lines in 7 files covered. (93.68%)

1 existing line in 1 file now uncovered.

1353 of 35639 relevant lines covered (3.8%)

0.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.84
/src/main/java/org/fiware/iam/tmforum/SpecificationGraphResolver.java
1
package org.fiware.iam.tmforum;
2

3
import io.micronaut.context.annotation.Requires;
4
import jakarta.inject.Singleton;
5
import lombok.RequiredArgsConstructor;
6
import lombok.extern.slf4j.Slf4j;
7
import org.fiware.iam.configuration.GeneralProperties;
8
import org.fiware.iam.exception.TMForumException;
9
import org.fiware.iam.tmforum.productcatalog.api.ProductSpecificationApiClient;
10
import org.fiware.iam.tmforum.productcatalog.model.BundledProductSpecificationVO;
11
import org.fiware.iam.tmforum.productcatalog.model.ProductSpecificationVO;
12
import org.fiware.iam.tmforum.productcatalog.model.ServiceSpecificationRefVO;
13
import org.fiware.iam.tmforum.servicecatalog.api.ServiceSpecificationApiClient;
14
import org.fiware.iam.tmforum.servicecatalog.model.ServiceSpecificationVO;
15
import reactor.core.publisher.Flux;
16
import reactor.core.publisher.Mono;
17

18
import java.util.ArrayList;
19
import java.util.List;
20
import java.util.Objects;
21
import java.util.Optional;
22
import java.util.Set;
23
import java.util.concurrent.ConcurrentHashMap;
24
import java.util.concurrent.atomic.AtomicBoolean;
25
import java.util.function.Function;
26

27
/**
28
 * Resolves a {@code ProductSpecification} into the specifications that carry its configuration.
29
 * <p>
30
 * A product can be composed: it may reference {@code ServiceSpecification}s that hold their own
31
 * policy configuration, and it may bundle other {@code ProductSpecification}s. This resolver walks
32
 * that graph once per order item and returns a flat list of nodes, so the callers can read the
33
 * configuration of a composed product exactly as they read the configuration of a flat one.
34
 * <p>
35
 * Three properties of the walk are deliberate:
36
 * <ul>
37
 *     <li><b>It is off by default.</b> Without
38
 *     {@code general.enableSpecificationComposition} the graph is the ordered specification and
39
 *     nothing else, so the behaviour is bit-for-bit the one before composition existed.</li>
40
 *     <li><b>It terminates.</b> {@code bundledProductSpecification} is not cycle-checked by the
41
 *     TMForum API, so every visited specification is remembered and the depth is limited by
42
 *     {@code general.specificationCompositionMaxDepth}. Hitting either guard <i>truncates</i> the
43
 *     walk and logs a warning naming the specification, the references that were skipped and the
44
 *     limit - a truncated grant is recoverable, a failed order is not, but a silent truncation would
45
 *     be missing access control nobody can see.</li>
46
 *     <li><b>A broken reference fails.</b> A referenced specification that cannot be read is a
47
 *     broken catalog rather than an empty configuration, and is raised as a
48
 *     {@link TMForumException}.</li>
49
 * </ul>
50
 * {@code ResourceSpecification}s are deliberately not traversed - they carry no connector
51
 * configuration.
52
 *
53
 * @see <a href="https://github.com/FIWARE/data-space-connector/blob/main/doc/tmforum/service-policy.md">Composed
54
 * ProductSpecifications in the FIWARE Data Space Connector</a>
55
 */
56
@Requires(condition = GeneralProperties.TmForumCondition.class)
57
@Singleton
58
@Slf4j
1✔
59
@RequiredArgsConstructor
60
public class SpecificationGraphResolver {
61

62
        private static final String SPECIFICATION_NOT_RESOLVABLE = "The %s specification %s referenced by specification %s could not be resolved.";
63
        private static final String KIND_SERVICE = "service";
64
        private static final String KIND_PRODUCT = "product";
65
        private static final String TRUNCATION_WARNING =
66
                        "The composition of specification {} is deeper than the configured limit of {} levels. "
67
                                        + "The following references are NOT resolved and their configuration is NOT applied: "
68
                                        + "services {}, bundled products {}.";
69

70
        private final GeneralProperties generalProperties;
71
        private final ProductSpecificationApiClient productSpecificationApiClient;
72
        private final ServiceSpecificationApiClient serviceSpecificationApiClient;
73

74
        /**
75
         * The kind of specification a node was resolved from.
76
         */
77
        public enum SpecificationKind {
1✔
78
                PRODUCT, SERVICE
1✔
79
        }
80

81
        /**
82
         * A party referenced by a specification, normalized over the per-API {@code RelatedParty} types.
83
         *
84
         * @param id   the TMForum id of the party
85
         * @param role the role the party has on the specification, may be {@code null}
86
         */
87
        public record PartyReference(String id, String role) {
1✔
88
        }
89

90
        /**
91
         * One specification of the resolved graph.
92
         *
93
         * @param id              the TMForum id of the specification
94
         * @param kind            whether it is the product or one of its services
95
         * @param characteristics its normalized characteristics
96
         * @param relatedParties  the parties it references
97
         */
98
        public record SpecificationNode(String id, SpecificationKind kind,
1✔
99
                        List<CharacteristicValues.Characteristic> characteristics, List<PartyReference> relatedParties) {
100
        }
101

102
        /**
103
         * The resolved composition of one ordered product.
104
         *
105
         * @param nodes     the ordered specification first, then the specifications it is composed of
106
         * @param truncated whether the walk stopped at the depth limit and configuration was left out
107
         */
108
        public record SpecificationGraph(List<SpecificationNode> nodes, boolean truncated) {
1✔
109

110
                /**
111
                 * The characteristics of every node, in traversal order.
112
                 *
113
                 * @return the aggregated characteristics
114
                 */
115
                public List<CharacteristicValues.Characteristic> characteristics() {
116
                        return nodes.stream().map(SpecificationNode::characteristics).flatMap(List::stream).toList();
1✔
117
                }
118
        }
119

120
        /**
121
         * Resolve the given specification and everything it is composed of.
122
         *
123
         * @param productSpecification the ordered specification, already read
124
         * @return the resolved graph, holding at least the given specification
125
         * @throws TMForumException if a referenced specification cannot be resolved
126
         */
127
        public Mono<SpecificationGraph> resolve(ProductSpecificationVO productSpecification) {
128
                SpecificationNode rootNode = toNode(productSpecification);
1✔
129
                if (!generalProperties.isEnableSpecificationComposition()) {
1✔
130
                        log.debug("Specification composition is disabled, only {} is resolved.", productSpecification.getId());
1✔
131
                        return Mono.just(new SpecificationGraph(List.of(rootNode), false));
1✔
132
                }
133
                Set<String> visited = ConcurrentHashMap.newKeySet();
1✔
134
                Optional.ofNullable(productSpecification.getId()).ifPresent(visited::add);
1✔
135
                AtomicBoolean truncated = new AtomicBoolean(false);
1✔
136

137
                return expand(productSpecification, visited, generalProperties.getSpecificationCompositionMaxDepth(), truncated)
1✔
138
                                .map(composedNodes -> {
1✔
139
                                        List<SpecificationNode> nodes = new ArrayList<>();
1✔
140
                                        nodes.add(rootNode);
1✔
141
                                        nodes.addAll(composedNodes);
1✔
142
                                        return new SpecificationGraph(List.copyOf(nodes), truncated.get());
1✔
143
                                });
144
        }
145

146
        private Mono<List<SpecificationNode>> expand(ProductSpecificationVO productSpecification, Set<String> visited,
147
                        int remainingDepth, AtomicBoolean truncated) {
148
                List<String> serviceIds = unvisitedReferences(productSpecification.getServiceSpecification(),
1✔
149
                                ServiceSpecificationRefVO::getId, visited);
150
                List<String> bundledIds = unvisitedReferences(productSpecification.getBundledProductSpecification(),
1✔
151
                                BundledProductSpecificationVO::getId, visited);
152

153
                if (serviceIds.isEmpty() && bundledIds.isEmpty()) {
1✔
154
                        return Mono.just(List.of());
1✔
155
                }
156
                if (remainingDepth <= 0) {
1✔
157
                        truncated.set(true);
1✔
158
                        log.warn(TRUNCATION_WARNING, productSpecification.getId(),
1✔
159
                                        generalProperties.getSpecificationCompositionMaxDepth(), serviceIds, bundledIds);
1✔
160
                        return Mono.just(List.of());
1✔
161
                }
162

163
                Mono<List<SpecificationNode>> services = Flux.fromIterable(serviceIds)
1✔
164
                                .flatMap(serviceId -> resolveServiceSpecification(serviceId, productSpecification.getId()))
1✔
165
                                .collectList();
1✔
166
                Mono<List<SpecificationNode>> bundled = Flux.fromIterable(bundledIds)
1✔
167
                                .flatMap(bundledId -> resolveProductSpecification(bundledId, productSpecification.getId())
1✔
168
                                                .flatMap(bundledSpecification -> expand(bundledSpecification, visited, remainingDepth - 1,
1✔
169
                                                                truncated)
170
                                                                .map(children -> withParentFirst(toNode(bundledSpecification), children))))
1✔
171
                                .collectList()
1✔
172
                                .map(nodesPerBundle -> nodesPerBundle.stream().flatMap(List::stream).toList());
1✔
173

174
                return Mono.zip(services, bundled, (serviceNodes, bundledNodes) -> {
1✔
175
                        List<SpecificationNode> nodes = new ArrayList<>(serviceNodes);
1✔
176
                        nodes.addAll(bundledNodes);
1✔
177
                        return List.copyOf(nodes);
1✔
178
                });
179
        }
180

181
        private Mono<SpecificationNode> resolveServiceSpecification(String serviceId, String referencedBy) {
182
                return serviceSpecificationApiClient.retrieveServiceSpecification(serviceId, null)
1✔
183
                                .flatMap(response -> {
1✔
184
                                        ServiceSpecificationVO serviceSpecification = response.body();
1✔
185
                                        if (serviceSpecification == null) {
1✔
186
                                                return Mono.error(unresolvableReference(KIND_SERVICE, serviceId, referencedBy));
1✔
187
                                        }
188
                                        return Mono.just(toNode(serviceSpecification));
1✔
189
                                })
190
                                .switchIfEmpty(Mono.error(() -> unresolvableReference(KIND_SERVICE, serviceId, referencedBy)));
1✔
191
        }
192

193
        private Mono<ProductSpecificationVO> resolveProductSpecification(String specificationId, String referencedBy) {
194
                return productSpecificationApiClient.retrieveProductSpecification(specificationId, null)
1✔
195
                                .flatMap(response -> {
1✔
196
                                        ProductSpecificationVO productSpecification = response.body();
1✔
197
                                        if (productSpecification == null) {
1✔
NEW
198
                                                return Mono.error(unresolvableReference(KIND_PRODUCT, specificationId, referencedBy));
×
199
                                        }
200
                                        return Mono.just(productSpecification);
1✔
201
                                })
202
                                .switchIfEmpty(Mono.error(() -> unresolvableReference(KIND_PRODUCT, specificationId, referencedBy)));
1✔
203
        }
204

205
        private SpecificationNode toNode(ProductSpecificationVO productSpecification) {
206
                return new SpecificationNode(productSpecification.getId(), SpecificationKind.PRODUCT,
1✔
207
                                CharacteristicValues.ofProductSpecification(productSpecification.getProductSpecCharacteristic()),
1✔
208
                                parties(productSpecification.getRelatedParty(),
1✔
209
                                                party -> new PartyReference(party.getId(), party.getRole())));
1✔
210
        }
211

212
        private SpecificationNode toNode(ServiceSpecificationVO serviceSpecification) {
213
                return new SpecificationNode(serviceSpecification.getId(), SpecificationKind.SERVICE,
1✔
214
                                CharacteristicValues.ofServiceSpecification(serviceSpecification.getSpecCharacteristic()),
1✔
215
                                parties(serviceSpecification.getRelatedParty(),
1✔
216
                                                party -> new PartyReference(party.getId(), party.getRole())));
1✔
217
        }
218

219
        private static <P> List<PartyReference> parties(List<P> relatedParties, Function<P, PartyReference> mapper) {
220
                return Optional.ofNullable(relatedParties)
1✔
221
                                .orElseGet(List::of)
1✔
222
                                .stream()
1✔
223
                                .filter(Objects::nonNull)
1✔
224
                                .map(mapper)
1✔
225
                                .filter(party -> party.id() != null)
1✔
226
                                .toList();
1✔
227
        }
228

229
        /**
230
         * Collect the ids of references that have not been seen yet, marking them as seen.
231
         * <p>
232
         * Marking happens here rather than at resolution time, so a specification referenced twice within
233
         * the same graph - and a cycle of bundled specifications - is resolved exactly once.
234
         */
235
        private static <R> List<String> unvisitedReferences(List<R> references, Function<R, String> idAccessor,
236
                        Set<String> visited) {
237
                return Optional.ofNullable(references)
1✔
238
                                .orElseGet(List::of)
1✔
239
                                .stream()
1✔
240
                                .filter(Objects::nonNull)
1✔
241
                                .map(idAccessor)
1✔
242
                                .filter(Objects::nonNull)
1✔
243
                                .filter(visited::add)
1✔
244
                                .toList();
1✔
245
        }
246

247
        /**
248
         * Log and build the exception for a specification that is referenced but cannot be read.
249
         * <p>
250
         * Must be invoked lazily from {@code switchIfEmpty} (via
251
         * {@link Mono#error(java.util.function.Supplier)}), since the arguments of {@code switchIfEmpty}
252
         * are evaluated when the pipeline is assembled, not when it fails.
253
         */
254
        private static TMForumException unresolvableReference(String kind, String specificationId, String referencedBy) {
255
                String message = SPECIFICATION_NOT_RESOLVABLE.formatted(kind, specificationId, referencedBy);
1✔
256
                log.error(message);
1✔
257
                return new TMForumException(message);
1✔
258
        }
259

260
        private static List<SpecificationNode> withParentFirst(SpecificationNode parent, List<SpecificationNode> children) {
261
                List<SpecificationNode> nodes = new ArrayList<>();
1✔
262
                nodes.add(parent);
1✔
263
                nodes.addAll(children);
1✔
264
                return List.copyOf(nodes);
1✔
265
        }
266
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc