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

FIWARE / contract-management / #105

01 Oct 2026 08:22AM UTC coverage: 4.489% (+0.7%) from 3.796%
#105

push

web-flow
Merge pull request #28 from FIWARE/topic/clearer-logging

Add failure reasons, downstream error descriptions and configurable log output

218 of 379 new or added lines in 33 files covered. (57.52%)

9 existing lines in 6 files now uncovered.

1606 of 35773 relevant lines covered (4.49%)

0.04 hits per line

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

92.44
/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 io.micronaut.core.annotation.Nullable;
12
import io.micronaut.http.client.exceptions.HttpClientResponseException;
13
import org.fiware.iam.exception.FailureReason;
14
import org.fiware.iam.exception.TMForumException;
15
import org.fiware.iam.til.model.CredentialsVO;
16
import org.fiware.iam.tmforum.productcatalog.api.ProductOfferingApiClient;
17
import org.fiware.iam.tmforum.productcatalog.api.ProductSpecificationApiClient;
18
import org.fiware.iam.tmforum.productcatalog.model.ProductSpecificationRefVO;
19
import org.fiware.iam.tmforum.productcatalog.model.*;
20
import org.fiware.iam.tmforum.productorder.model.ProductOfferingRefVO;
21
import org.fiware.iam.tmforum.productorder.model.*;
22
import org.fiware.iam.tmforum.quote.api.QuoteApiClient;
23
import org.fiware.iam.tmforum.quote.model.QuoteItemVO;
24
import org.fiware.iam.tmforum.quote.model.QuoteStateTypeVO;
25
import org.fiware.iam.tmforum.quote.model.QuoteVO;
26
import reactor.core.publisher.Mono;
27

28
import java.util.LinkedHashSet;
29
import java.util.List;
30
import java.util.Objects;
31
import java.util.Optional;
32
import java.util.stream.Stream;
33

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

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

76
    private final ObjectMapper objectMapper;
77
    private final OrganizationResolver organizationResolver;
78

79
    private final ProductOfferingApiClient productOfferingApiClient;
80
    private final ProductSpecificationApiClient productSpecificationApiClient;
81
    private final QuoteApiClient quoteApiClient;
82
    private final SpecificationGraphResolver specificationGraphResolver;
83

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

112
        return zipToList(credentialsVOMonoList);
1 ✔
113
    }
114

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

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

144
    private Mono<CredentialConfig> getCredentialsConfigFromOffer(String offerId) {
145
        return productOfferingApiClient
1 ✔
146
                .retrieveProductOffering(offerId, null)
1 ✔
147
                .onErrorMap(HttpClientResponseException.class, e -> unresolvableReference(FailureReason.OFFERING_NOT_RESOLVABLE,
1 ✔
NEW
148
                        OFFERING_NOT_RESOLVABLE.formatted(offerId), e))
×
149
                .flatMap(response -> getCredentialsConfigFromSpecificationOf(response.body(), offerId))
1 ✔
150
                .switchIfEmpty(Mono.error(() -> unresolvableReference(FailureReason.OFFERING_NOT_RESOLVABLE,
1 ✔
151
                        OFFERING_NOT_RESOLVABLE.formatted(offerId), null)));
1 ✔
152
    }
153

154
    private Mono<CredentialConfig> getCredentialsConfigFromSpecificationOf(ProductOfferingVO productOffering,
155
            String offerId) {
156
        if (productOffering == null) {
1 ✔
157
            return Mono.error(unresolvableReference(FailureReason.OFFERING_NOT_RESOLVABLE,
1 ✔
158
                        OFFERING_NOT_RESOLVABLE.formatted(offerId), null));
1 ✔
159
        }
160
        String specificationId = Optional.ofNullable(productOffering.getProductSpecification())
1 ✔
161
                .map(ProductSpecificationRefVO::getId)
1 ✔
162
                .orElse(null);
1 ✔
163
        if (specificationId == null) {
1 ✔
164
            // bundled offerings do not reference a specification of their own - nothing to configure here
165
            log.debug("The offering {} does not reference a product specification, no credentials config will be resolved.",
1 ✔
166
                    productOffering.getId());
1 ✔
167
            return Mono.just(emptyConfig());
1 ✔
168
        }
169
        return productSpecificationApiClient.retrieveProductSpecification(specificationId, null)
1 ✔
170
                .onErrorMap(HttpClientResponseException.class, e -> unresolvableReference(FailureReason.SPECIFICATION_NOT_RESOLVABLE,
1 ✔
NEW
171
                        SPECIFICATION_NOT_RESOLVABLE.formatted(specificationId, offerId), e))
×
172
                .flatMap(response -> toCredentialConfig(response.body(), specificationId, offerId))
1 ✔
173
                .switchIfEmpty(Mono.error(() -> unresolvableReference(FailureReason.SPECIFICATION_NOT_RESOLVABLE,
1 ✔
174
                        SPECIFICATION_NOT_RESOLVABLE.formatted(specificationId, offerId), null)));
1 ✔
175
    }
176

177
    private Mono<CredentialConfig> toCredentialConfig(ProductSpecificationVO productSpecification,
178
            String specificationId, String offerId) {
179
        if (productSpecification == null) {
1 ✔
NEW
180
            return Mono.error(unresolvableReference(FailureReason.SPECIFICATION_NOT_RESOLVABLE,
×
NEW
181
                        SPECIFICATION_NOT_RESOLVABLE.formatted(specificationId, offerId), null));
×
182
        }
183
        return specificationGraphResolver.resolve(productSpecification)
1 ✔
184
                .flatMap(graph -> toCredentialConfig(graph, productSpecification.getId()));
1 ✔
185
    }
186

187
    private Mono<CredentialConfig> toCredentialConfig(SpecificationGraphResolver.SpecificationGraph graph,
188
            String specificationId) {
189
        List<CredentialsVO> credentialsVOS = aggregateCredentials(graph);
1 ✔
190
        log.debug("Specification {} configures the credentials {}.", specificationId,
1 ✔
191
                credentialsVOS.stream().map(CredentialsVO::getCredentialsType).toList());
1 ✔
192
        return governingProvider(graph, specificationId)
1 ✔
193
                .map(id -> organizationResolver.getContractManagement(id)
1 ✔
194
                        .map(cm -> new CredentialConfig(cm, credentialsVOS))
1 ✔
195
                        // a referenced provider that cannot be resolved is a broken reference, not an empty config
196
                        .switchIfEmpty(Mono.error(() -> unresolvableReference(FailureReason.PROVIDER_NOT_RESOLVABLE,
1 ✔
197
                        PROVIDER_NOT_RESOLVABLE.formatted(id, specificationId), null))))
1 ✔
198
                .orElseGet(() -> Mono.just(new CredentialConfig(new ContractManagement(true), credentialsVOS)));
1 ✔
199
    }
200

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

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

251
    private Mono<List<CredentialConfig>> getCredentialsConfigFromQuote(List<QuoteRefVO> quoteRefVOS) {
252
        return zipToFlatList(quoteRefVOS.stream()
1 ✔
253
                .filter(Objects::nonNull)
1 ✔
254
                .map(QuoteRefVO::getId)
1 ✔
255
                .filter(Objects::nonNull)
1 ✔
256
                .map(quoteId -> quoteApiClient.retrieveQuote(quoteId, null)
1 ✔
257
                        .onErrorMap(HttpClientResponseException.class, e -> unresolvableReference(FailureReason.QUOTE_NOT_RESOLVABLE,
1 ✔
NEW
258
                                QUOTE_NOT_RESOLVABLE.formatted(quoteId), e))
×
259
                        .flatMap(response -> getCredentialsConfigFrom(response.body(), quoteId))
1 ✔
260
                        .switchIfEmpty(Mono.error(() -> unresolvableReference(FailureReason.QUOTE_NOT_RESOLVABLE,
1 ✔
261
                        QUOTE_NOT_RESOLVABLE.formatted(quoteId), null))))
1 ✔
262
                .toList());
1 ✔
263
    }
264

265
    private Mono<List<CredentialConfig>> getCredentialsConfigFrom(QuoteVO quote, String quoteId) {
266
        if (quote == null) {
1 ✔
NEW
267
            return Mono.error(unresolvableReference(FailureReason.QUOTE_NOT_RESOLVABLE,
×
NEW
268
                        QUOTE_NOT_RESOLVABLE.formatted(quoteId), null));
×
269
        }
270
        if (quote.getState() != QuoteStateTypeVO.ACCEPTED) {
1 ✔
271
            // a quote that is not accepted (anymore) configures nothing
272
            log.debug("The quote {} is in state {}, no credentials config will be resolved.", quoteId,
1 ✔
273
                    quote.getState());
1 ✔
274
            return Mono.just(List.of());
1 ✔
275
        }
276
        return getCredentialsConfigFromQuoteItems(quote.getQuoteItem());
1 ✔
277
    }
278

279
    private Mono<List<CredentialConfig>> getCredentialsConfigFromQuoteItems(List<QuoteItemVO> quoteItems) {
280
        return zipToList(Optional.ofNullable(quoteItems)
1 ✔
281
                .orElseGet(List::of)
1 ✔
282
                .stream()
1 ✔
283
                .filter(Objects::nonNull)
1 ✔
284
                .filter(item -> QuoteStateTypeVO.ACCEPTED.getValue().equals(item.getState()))
1 ✔
285
                .filter(item -> !QUOTE_DELETE_ACTION.equals(item.getAction()))
1 ✔
286
                .map(QuoteItemVO::getProductOffering)
1 ✔
287
                .filter(Objects::nonNull)
1 ✔
288
                .map(org.fiware.iam.tmforum.quote.model.ProductOfferingRefVO::getId)
1 ✔
289
                .filter(Objects::nonNull)
1 ✔
290
                .map(this::getCredentialsConfigFromOffer)
1 ✔
291
                .toList());
1 ✔
292
    }
293

294
    private List<CredentialsVO> getCredentialsConfigFromPSC(List<ProductSpecificationCharacteristicVO> pscList) {
295
        return getCredentialsConfigFrom(CharacteristicValues.ofProductSpecification(pscList));
×
296
    }
297

298
    /**
299
     * Read the credential configuration from already normalized characteristics.
300
     * <p>
301
     * Only the first matching characteristic is read, which is the behaviour every writer in the data
302
     * space currently relies on.
303
     *
304
     * @param characteristics the characteristics of one or more specifications
305
     * @return the configured credentials, empty if none is configured
306
     */
307
    private List<CredentialsVO> getCredentialsConfigFrom(
308
            List<CharacteristicValues.Characteristic> characteristics) {
309
        return CharacteristicValues.byValueType(characteristics, CREDENTIALS_CONFIG_KEY)
1 ✔
310
                .map(characteristic -> CharacteristicValues.flatten(objectMapper, characteristic, CREDENTIALS_TYPE))
1 ✔
311
                .orElseGet(List::of);
1 ✔
312
    }
313

314
    private static CredentialConfig emptyConfig() {
315
        return new CredentialConfig(new ContractManagement(true), List.of());
1 ✔
316
    }
317

318
    /**
319
     * Build the exception for a configuration that is referenced but cannot be read. It is not logged
320
     * here: the order handler logs it once, together with the order it belongs to.
321
     *
322
     * @param reason  the failure reason
323
     * @param message what could not be resolved
324
     * @param cause   the failed call, if any
325
     * @return the exception to raise
326
     */
327
    private static TMForumException unresolvableReference(FailureReason reason, String message, @Nullable Throwable cause) {
328
        return new TMForumException(reason, message, cause);
1 ✔
329
    }
330

331
    /**
332
     * The credential configuration of one offering, together with the contract-management responsible
333
     * for granting it.
334
     *
335
     * @param contractManagement the responsible contract-management, local unless the provider declares one
336
     * @param credentialsVOS     the configured credentials, possibly empty
337
     */
338
    public record CredentialConfig(ContractManagement contractManagement, List<CredentialsVO> credentialsVOS) {
1 ✔
339
    }
340
}
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