• 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

95.5
/src/main/java/org/fiware/iam/tmforum/CredentialsConfigResolver.java
1
package org.fiware.iam.tmforum;
2

3
import com.fasterxml.jackson.core.type.TypeReference;
4
import com.fasterxml.jackson.databind.ObjectMapper;
5
import io.micronaut.context.annotation.Requires;
6
import jakarta.inject.Singleton;
7
import lombok.RequiredArgsConstructor;
8
import lombok.extern.slf4j.Slf4j;
9
import org.fiware.iam.configuration.GeneralProperties;
10
import org.fiware.iam.domain.ContractManagement;
11
import org.fiware.iam.exception.TMForumException;
12
import org.fiware.iam.til.model.CredentialsVO;
13
import org.fiware.iam.tmforum.productcatalog.api.ProductOfferingApiClient;
14
import org.fiware.iam.tmforum.productcatalog.api.ProductSpecificationApiClient;
15
import org.fiware.iam.tmforum.productcatalog.model.ProductSpecificationRefVO;
16
import org.fiware.iam.tmforum.productcatalog.model.*;
17
import org.fiware.iam.tmforum.productorder.model.ProductOfferingRefVO;
18
import org.fiware.iam.tmforum.productorder.model.*;
19
import org.fiware.iam.tmforum.quote.api.QuoteApiClient;
20
import org.fiware.iam.tmforum.quote.model.QuoteItemVO;
21
import org.fiware.iam.tmforum.quote.model.QuoteStateTypeVO;
22
import org.fiware.iam.tmforum.quote.model.QuoteVO;
23
import reactor.core.publisher.Mono;
24

25
import java.util.LinkedHashSet;
26
import java.util.List;
27
import java.util.Objects;
28
import java.util.Optional;
29
import java.util.stream.Stream;
30

31
/**
32
 * Extract the credential configuration from ProductOrders, either from the connected Quote or
33
 * ProductSpec.
34
 * <p>
35
 * Resolution distinguishes two cases that used to look the same:
36
 * <ul>
37
 *     <li><b>Nothing is configured.</b> An order without items, an offering that bundles others
38
 *     instead of referencing a specification, a specification without a
39
 *     {@code credentialsConfiguration} characteristic - all of these legitimately configure no
40
 *     credential and contribute an empty configuration. They must not fail the resolution, because
41
 *     the result is consumed inside a TMForum notification handler: an aborted resolution answers
42
 *     the hub with an error, the hub redelivers the notification, and every other handler of the
43
 *     same order runs again.</li>
44
 *     <li><b>A referenced configuration cannot be resolved.</b> An offering, specification, quote or
45
 *     provider that is referenced but cannot be read is a broken catalog, not an empty
46
 *     configuration. It is logged and raised as a {@link TMForumException} rather than silently
47
 *     ignored - activating an order while parts of its configuration could not be read would grant
48
 *     access nobody can account for.</li>
49
 * </ul>
50
 * <p>
51
 * When the ordered specification is composed of {@code ServiceSpecification}s, the credential
52
 * configuration of every part is <b>unioned</b>: the effective configuration of a product is the
53
 * union over the product and its parts, de-duplicated by value. Note that the trusted-issuers-list
54
 * evaluates several configurations of the same credential type as an OR, so the union is a widening
55
 * operation - a permissive part relaxes a restrictive one.
56
 */
57
@Requires(condition = GeneralProperties.TmForumCondition.class)
58
@Singleton
59
@Slf4j
1✔
60
@RequiredArgsConstructor
61
public class CredentialsConfigResolver {
62

63
    private static final String CREDENTIALS_CONFIG_KEY = "credentialsConfiguration";
64
    private static final String QUOTE_DELETE_ACTION = "delete";
65
    private static final String OFFERING_NOT_RESOLVABLE = "The referenced product offering %s could not be resolved.";
66
    private static final String SPECIFICATION_NOT_RESOLVABLE = "The product specification %s referenced by offering %s could not be resolved.";
67
    private static final String PROVIDER_NOT_RESOLVABLE = "The contract-management of provider %s referenced by product specification %s could not be resolved.";
68
    private static final String QUOTE_NOT_RESOLVABLE = "The quote %s referenced by the order could not be resolved.";
69
    private static final String CONFLICTING_PROVIDERS = "The composition of specification %s declares more than one provider: %s. Composition across providers is not supported.";
70
    private static final TypeReference<CredentialsVO> CREDENTIALS_TYPE = new TypeReference<>() {
1✔
71
    };
72

73
    private final ObjectMapper objectMapper;
74
    private final OrganizationResolver organizationResolver;
75

76
    private final ProductOfferingApiClient productOfferingApiClient;
77
    private final ProductSpecificationApiClient productSpecificationApiClient;
78
    private final QuoteApiClient quoteApiClient;
79
    private final SpecificationGraphResolver specificationGraphResolver;
80

81
    /**
82
     * Resolve the credential configurations for the given order.
83
     * <p>
84
     * The configuration is taken from the accepted quote when the order references one, and from the
85
     * ordered offerings otherwise.
86
     *
87
     * @param productOrder the completed (or stopped) order
88
     * @return one configuration per resolved offering, empty list if the order configures nothing
89
     * @throws TMForumException if a referenced offering, specification, quote or provider cannot be resolved
90
     */
91
    public Mono<List<CredentialConfig>> getCredentialsConfig(ProductOrderVO productOrder) {
92
        if (productOrder.getQuote() != null && !productOrder.getQuote().isEmpty()) {
1✔
93
            return getCredentialsConfigFromQuote(productOrder.getQuote());
1✔
94
        }
95
        log.debug("No quote found, take the original offer from the order item.");
1✔
96
        List<Mono<CredentialConfig>> credentialsVOMonoList = Optional
1✔
97
                .ofNullable(productOrder.getProductOrderItem())
1✔
98
                .orElseGet(List::of)
1✔
99
                .stream()
1✔
100
                .filter(Objects::nonNull)
1✔
101
                .filter(poi -> poi.getAction() == OrderItemActionTypeVO.ADD || poi.getAction() == OrderItemActionTypeVO.MODIFY)
1✔
102
                .map(ProductOrderItemVO::getProductOffering)
1✔
103
                .filter(Objects::nonNull)
1✔
104
                .map(ProductOfferingRefVO::getId)
1✔
105
                .filter(Objects::nonNull)
1✔
106
                .map(this::getCredentialsConfigFromOffer)
1✔
107
                .toList();
1✔
108

109
        return zipToList(credentialsVOMonoList);
1✔
110
    }
111

112
    /**
113
     * Combine the per-offering resolutions into one list.
114
     * <p>
115
     * {@link Mono#zip(Iterable, java.util.function.Function)} completes <i>empty</i> for an empty
116
     * iterable, which would silently drop the whole order, so the empty case is answered with an
117
     * empty list instead. Every element mono is guaranteed to either emit exactly one value or fail.
118
     */
119
    private static <T> Mono<List<T>> zipToList(List<Mono<T>> monoList) {
120
        if (monoList.isEmpty()) {
1✔
121
            return Mono.just(List.of());
1✔
122
        }
123
        return Mono.zip(monoList, results -> Stream.of(results).map(result -> (T) result).toList());
1✔
124
    }
125

126
    /**
127
     * Combine resolutions that each already yield a list, flattening the result.
128
     *
129
     * @see #zipToList(List)
130
     */
131
    private static <T> Mono<List<T>> zipToFlatList(List<Mono<List<T>>> monoList) {
132
        if (monoList.isEmpty()) {
1✔
NEW
133
            return Mono.just(List.of());
×
134
        }
135
        return Mono.zip(monoList, results -> Stream.of(results)
1✔
136
                .map(result -> (List<T>) result)
1✔
137
                .flatMap(List::stream)
1✔
138
                .toList());
1✔
139
    }
140

141
    private Mono<CredentialConfig> getCredentialsConfigFromOffer(String offerId) {
142
        return productOfferingApiClient
1✔
143
                .retrieveProductOffering(offerId, null)
1✔
144
                .flatMap(response -> getCredentialsConfigFromSpecificationOf(response.body(), offerId))
1✔
145
                .switchIfEmpty(Mono.error(() -> unresolvableReference(OFFERING_NOT_RESOLVABLE.formatted(offerId))));
1✔
146
    }
147

148
    private Mono<CredentialConfig> getCredentialsConfigFromSpecificationOf(ProductOfferingVO productOffering,
149
            String offerId) {
150
        if (productOffering == null) {
1✔
151
            return Mono.error(unresolvableReference(OFFERING_NOT_RESOLVABLE.formatted(offerId)));
1✔
152
        }
153
        String specificationId = Optional.ofNullable(productOffering.getProductSpecification())
1✔
154
                .map(ProductSpecificationRefVO::getId)
1✔
155
                .orElse(null);
1✔
156
        if (specificationId == null) {
1✔
157
            // bundled offerings do not reference a specification of their own - nothing to configure here
158
            log.info("The offering {} does not reference a product specification, no credentials config will be resolved.",
1✔
159
                    productOffering.getId());
1✔
160
            return Mono.just(emptyConfig());
1✔
161
        }
162
        return productSpecificationApiClient.retrieveProductSpecification(specificationId, null)
1✔
163
                .flatMap(response -> toCredentialConfig(response.body(), specificationId, offerId))
1✔
164
                .switchIfEmpty(Mono.error(() -> unresolvableReference(
1✔
165
                        SPECIFICATION_NOT_RESOLVABLE.formatted(specificationId, offerId))));
1✔
166
    }
167

168
    private Mono<CredentialConfig> toCredentialConfig(ProductSpecificationVO productSpecification,
169
            String specificationId, String offerId) {
170
        if (productSpecification == null) {
1✔
NEW
171
            return Mono.error(unresolvableReference(
×
NEW
172
                    SPECIFICATION_NOT_RESOLVABLE.formatted(specificationId, offerId)));
×
173
        }
174
        return specificationGraphResolver.resolve(productSpecification)
1✔
175
                .flatMap(graph -> toCredentialConfig(graph, productSpecification.getId()));
1✔
176
    }
177

178
    private Mono<CredentialConfig> toCredentialConfig(SpecificationGraphResolver.SpecificationGraph graph,
179
            String specificationId) {
180
        List<CredentialsVO> credentialsVOS = aggregateCredentials(graph);
1✔
181
        return governingProvider(graph, specificationId)
1✔
182
                .map(id -> organizationResolver.getContractManagement(id)
1✔
183
                        .map(cm -> new CredentialConfig(cm, credentialsVOS))
1✔
184
                        // a referenced provider that cannot be resolved is a broken reference, not an empty config
185
                        .switchIfEmpty(Mono.error(() -> unresolvableReference(
1✔
186
                                PROVIDER_NOT_RESOLVABLE.formatted(id, specificationId)))))
1✔
187
                .orElseGet(() -> Mono.just(new CredentialConfig(new ContractManagement(true), credentialsVOS)));
1✔
188
    }
189

190
    /**
191
     * Union the credential configuration of every specification in the composition.
192
     * <p>
193
     * The first matching characteristic is read <i>per specification</i>, so a composed product
194
     * contributes one credential configuration per part rather than only the first one found.
195
     * Identical entries are de-duplicated, since a service specification shared by several parts of
196
     * the same product is normal - and the trusted-issuers-list de-duplicates by value as well.
197
     *
198
     * @param graph the resolved composition
199
     * @return the effective credential configuration of the product
200
     */
201
    private List<CredentialsVO> aggregateCredentials(SpecificationGraphResolver.SpecificationGraph graph) {
202
        return List.copyOf(new LinkedHashSet<>(graph.nodes()
1✔
203
                .stream()
1✔
204
                .map(SpecificationGraphResolver.SpecificationNode::characteristics)
1✔
205
                .map(this::getCredentialsConfigFrom)
1✔
206
                .flatMap(List::stream)
1✔
207
                .toList()));
1✔
208
    }
209

210
    /**
211
     * The single provider responsible for the whole composition.
212
     * <p>
213
     * One order activates at exactly one contract-management, so a composition that declares more
214
     * than one provider is refused: splitting an activation across two contract-managements has no
215
     * rollback story - one side would grant and the other would not. A part that declares no provider
216
     * inherits the one of the composition, which is the shape BAE produces (it replaces
217
     * {@code relatedParty} with commercial roles only).
218
     *
219
     * @param graph           the resolved composition
220
     * @param specificationId the ordered specification, for the error message
221
     * @return the responsible provider, or empty if the composition declares none
222
     * @throws TMForumException if the composition declares more than one provider
223
     */
224
    private Optional<String> governingProvider(SpecificationGraphResolver.SpecificationGraph graph,
225
            String specificationId) {
226
        List<String> providers = graph.nodes()
1✔
227
                .stream()
1✔
228
                .map(SpecificationGraphResolver.SpecificationNode::relatedParties)
1✔
229
                .flatMap(List::stream)
1✔
230
                .filter(party -> organizationResolver.hasProviderRole(party.role()))
1✔
231
                .map(SpecificationGraphResolver.PartyReference::id)
1✔
232
                .distinct()
1✔
233
                .toList();
1✔
234
        if (providers.size() > 1) {
1✔
235
            String message = CONFLICTING_PROVIDERS.formatted(specificationId, providers);
1✔
236
            log.error(message);
1✔
237
            throw new TMForumException(message);
1✔
238
        }
239
        return providers.stream().findFirst();
1✔
240
    }
241

242
    private Mono<List<CredentialConfig>> getCredentialsConfigFromQuote(List<QuoteRefVO> quoteRefVOS) {
243
        return zipToFlatList(quoteRefVOS.stream()
1✔
244
                .filter(Objects::nonNull)
1✔
245
                .map(QuoteRefVO::getId)
1✔
246
                .filter(Objects::nonNull)
1✔
247
                .map(quoteId -> quoteApiClient.retrieveQuote(quoteId, null)
1✔
248
                        .flatMap(response -> getCredentialsConfigFrom(response.body(), quoteId))
1✔
249
                        .switchIfEmpty(Mono.error(() -> unresolvableReference(
1✔
250
                                QUOTE_NOT_RESOLVABLE.formatted(quoteId)))))
1✔
251
                .toList());
1✔
252
    }
253

254
    private Mono<List<CredentialConfig>> getCredentialsConfigFrom(QuoteVO quote, String quoteId) {
255
        if (quote == null) {
1✔
NEW
256
            return Mono.error(unresolvableReference(QUOTE_NOT_RESOLVABLE.formatted(quoteId)));
×
257
        }
258
        if (quote.getState() != QuoteStateTypeVO.ACCEPTED) {
1✔
259
            // a quote that is not accepted (anymore) configures nothing
260
            log.debug("The quote {} is in state {}, no credentials config will be resolved.", quoteId,
1✔
261
                    quote.getState());
1✔
262
            return Mono.just(List.of());
1✔
263
        }
264
        return getCredentialsConfigFromQuoteItems(quote.getQuoteItem());
1✔
265
    }
266

267
    private Mono<List<CredentialConfig>> getCredentialsConfigFromQuoteItems(List<QuoteItemVO> quoteItems) {
268
        return zipToList(Optional.ofNullable(quoteItems)
1✔
269
                .orElseGet(List::of)
1✔
270
                .stream()
1✔
271
                .filter(Objects::nonNull)
1✔
272
                .filter(item -> QuoteStateTypeVO.ACCEPTED.getValue().equals(item.getState()))
1✔
273
                .filter(item -> !QUOTE_DELETE_ACTION.equals(item.getAction()))
1✔
274
                .map(QuoteItemVO::getProductOffering)
1✔
275
                .filter(Objects::nonNull)
1✔
276
                .map(org.fiware.iam.tmforum.quote.model.ProductOfferingRefVO::getId)
1✔
277
                .filter(Objects::nonNull)
1✔
278
                .map(this::getCredentialsConfigFromOffer)
1✔
279
                .toList());
1✔
280
    }
281

282
    private List<CredentialsVO> getCredentialsConfigFromPSC(List<ProductSpecificationCharacteristicVO> pscList) {
NEW
283
        return getCredentialsConfigFrom(CharacteristicValues.ofProductSpecification(pscList));
×
284
    }
285

286
    /**
287
     * Read the credential configuration from already normalized characteristics.
288
     * <p>
289
     * Only the first matching characteristic is read, which is the behaviour every writer in the data
290
     * space currently relies on.
291
     *
292
     * @param characteristics the characteristics of one or more specifications
293
     * @return the configured credentials, empty if none is configured
294
     */
295
    private List<CredentialsVO> getCredentialsConfigFrom(
296
            List<CharacteristicValues.Characteristic> characteristics) {
297
        return CharacteristicValues.byValueType(characteristics, CREDENTIALS_CONFIG_KEY)
1✔
298
                .map(characteristic -> CharacteristicValues.flatten(objectMapper, characteristic, CREDENTIALS_TYPE))
1✔
299
                .orElseGet(List::of);
1✔
300
    }
301

302
    private static CredentialConfig emptyConfig() {
303
        return new CredentialConfig(new ContractManagement(true), List.of());
1✔
304
    }
305

306
    /**
307
     * Log and build the exception for a configuration that is referenced but cannot be read.
308
     * <p>
309
     * Only ever called on the failing path, so it is safe to log here - but it must be invoked
310
     * lazily (via {@link Mono#error(java.util.function.Supplier)}), since the arguments of
311
     * {@code switchIfEmpty} are evaluated when the pipeline is assembled, not when it fails.
312
     *
313
     * @param message what could not be resolved
314
     * @return the exception to raise
315
     */
316
    private static TMForumException unresolvableReference(String message) {
317
        log.error(message);
1✔
318
        return new TMForumException(message);
1✔
319
    }
320

321
    /**
322
     * The credential configuration of one offering, together with the contract-management responsible
323
     * for granting it.
324
     *
325
     * @param contractManagement the responsible contract-management, local unless the provider declares one
326
     * @param credentialsVOS     the configured credentials, possibly empty
327
     */
328
    public record CredentialConfig(ContractManagement contractManagement, List<CredentialsVO> credentialsVOS) {
1✔
329
    }
330
}
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