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

FIWARE / contract-management / #104

01 Oct 2026 07:34AM UTC coverage: 4.489% (+0.7%) from 3.796%
#104

Pull #28

vramperez
Remove unused exception constructors, explain the handler defer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pull Request #28: 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

93.55
/src/main/java/org/fiware/iam/tmforum/PolicyResolver.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.tmforum.productcatalog.api.ProductOfferingApiClient;
16
import org.fiware.iam.tmforum.productcatalog.api.ProductSpecificationApiClient;
17
import org.fiware.iam.tmforum.productcatalog.model.ProductSpecificationRefVO;
18
import org.fiware.iam.tmforum.productcatalog.model.*;
19
import org.fiware.iam.tmforum.productorder.model.ProductOfferingRefVO;
20
import org.fiware.iam.tmforum.productorder.model.*;
21
import org.fiware.iam.tmforum.quote.api.QuoteApiClient;
22
import org.fiware.iam.tmforum.quote.model.QuoteItemVO;
23
import org.fiware.iam.tmforum.quote.model.QuoteStateTypeVO;
24
import org.fiware.iam.tmforum.quote.model.QuoteVO;
25
import reactor.core.publisher.Mono;
26

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

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

66
    private static final String AUTHORIZATION_POLICY_KEY = "authorizationPolicy";
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_POLICIES = "The composition of specification %s contains two different policies claiming the uid %s. Refusing to install either of them.";
73
    private static final String CONFLICTING_PROVIDERS = "The composition of specification %s declares more than one provider: %s. Composition across providers is not supported.";
74
    private static final String ODRL_UID_KEY = "odrl:uid";
75
    private static final TypeReference<Map<String, Object>> POLICY_TYPE = new TypeReference<>() {
1 ✔
76
    };
77

78
    private final ObjectMapper objectMapper;
79

80
    private final ProductOfferingApiClient productOfferingApiClient;
81
    private final ProductSpecificationApiClient productSpecificationApiClient;
82
    private final QuoteApiClient quoteApiClient;
83
    private final OrganizationResolver organizationResolver;
84
    private final SpecificationGraphResolver specificationGraphResolver;
85

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

114
        return zipToList(policyConfigMonoList);
1 ✔
115
    }
116

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

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

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

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

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

188
    private Mono<PolicyConfig> toPolicyConfig(SpecificationGraphResolver.SpecificationGraph graph,
189
            String specificationId) {
190
        List<Map<String, Object>> policies = aggregatePolicies(graph, specificationId);
1 ✔
191
        log.debug("Specification {} configures the policies {}.", specificationId,
1 ✔
192
                policies.stream().map(policy -> policy.getOrDefault(ODRL_UID_KEY, "<no uid>")).toList());
1 ✔
193
        return governingProvider(graph, specificationId)
1 ✔
194
                .map(id -> organizationResolver.getContractManagement(id)
1 ✔
195
                        .map(cm -> new PolicyConfig(cm, policies))
1 ✔
196
                        // a referenced provider that cannot be resolved is a broken reference, not an empty config
197
                        .switchIfEmpty(Mono.error(() -> unresolvableReference(FailureReason.PROVIDER_NOT_RESOLVABLE,
1 ✔
198
                                PROVIDER_NOT_RESOLVABLE.formatted(id, specificationId), null))))
1 ✔
199
                .orElseGet(() -> Mono.just(new PolicyConfig(new ContractManagement(true), policies)));
1 ✔
200
    }
201

202
    /**
203
     * Union the policies of every specification in the composition.
204
     * <p>
205
     * The first matching characteristic is read <i>per specification</i>, so a composed product
206
     * contributes one policy configuration per part rather than only the first one found. Identical
207
     * policies are de-duplicated silently - a service specification shared by several parts of the
208
     * same product is normal.
209
     *
210
     * @param graph           the resolved composition
211
     * @param specificationId the ordered specification, for the error message
212
     * @return the effective policies of the product
213
     * @throws TMForumException if two different policies claim the same {@code odrl:uid}
214
     */
215
    private List<Map<String, Object>> aggregatePolicies(SpecificationGraphResolver.SpecificationGraph graph,
216
            String specificationId) {
217
        List<Map<String, Object>> policies = graph.nodes()
1 ✔
218
                .stream()
1 ✔
219
                .map(SpecificationGraphResolver.SpecificationNode::characteristics)
1 ✔
220
                .map(this::getAuthorizationPolicyFrom)
1 ✔
221
                .flatMap(List::stream)
1 ✔
222
                .toList();
1 ✔
223

224
        Map<Object, Map<String, Object>> byUid = new LinkedHashMap<>();
1 ✔
225
        policies.forEach(policy -> {
1 ✔
226
            // a policy without a uid cannot be keyed by one - it is then only de-duplicated against
227
            // an identical copy of itself, and the ODRL-PAP rejects it later on anyway
228
            Object uid = policy.getOrDefault(ODRL_UID_KEY, policy);
1 ✔
229
            Map<String, Object> known = byUid.putIfAbsent(uid, policy);
1 ✔
230
            if (known != null && !known.equals(policy)) {
1 ✔
231
                throw new TMForumException(FailureReason.CONFLICTING_POLICIES, CONFLICTING_POLICIES.formatted(specificationId, uid));
1 ✔
232
            }
233
        });
1 ✔
234
        return List.copyOf(byUid.values());
1 ✔
235
    }
236

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

267
    private Mono<List<PolicyConfig>> getAuthorizationPolicyFromQuote(List<QuoteRefVO> quoteRefVOS) {
268
        return zipToFlatList(quoteRefVOS.stream()
1 ✔
269
                .filter(Objects::nonNull)
1 ✔
270
                .map(QuoteRefVO::getId)
1 ✔
271
                .filter(Objects::nonNull)
1 ✔
272
                .map(quoteId -> quoteApiClient.retrieveQuote(quoteId, null)
1 ✔
273
                        .onErrorMap(HttpClientResponseException.class, e -> unresolvableReference(FailureReason.QUOTE_NOT_RESOLVABLE,
1 ✔
NEW
274
                                QUOTE_NOT_RESOLVABLE.formatted(quoteId), e))
×
275
                        .flatMap(response -> getAuthorizationPolicyFrom(response.body(), quoteId))
1 ✔
276
                        .switchIfEmpty(Mono.error(() -> unresolvableReference(FailureReason.QUOTE_NOT_RESOLVABLE,
1 ✔
277
                                QUOTE_NOT_RESOLVABLE.formatted(quoteId), null))))
1 ✔
278
                .toList());
1 ✔
279
    }
280

281
    private Mono<List<PolicyConfig>> getAuthorizationPolicyFrom(QuoteVO quote, String quoteId) {
282
        if (quote == null) {
1 ✔
NEW
283
            return Mono.error(unresolvableReference(FailureReason.QUOTE_NOT_RESOLVABLE, QUOTE_NOT_RESOLVABLE.formatted(quoteId), null));
×
284
        }
285
        if (quote.getState() != QuoteStateTypeVO.ACCEPTED) {
1 ✔
286
            // a quote that is not accepted (anymore) configures nothing
287
            log.debug("The quote {} is in state {}, no policy will be resolved.", quoteId, quote.getState());
1 ✔
288
            return Mono.just(List.of());
1 ✔
289
        }
290
        return getAuthorizationPolicyFromQuoteItems(quote.getQuoteItem());
1 ✔
291
    }
292

293
    private Mono<List<PolicyConfig>> getAuthorizationPolicyFromQuoteItems(List<QuoteItemVO> quoteItems) {
294
        return zipToList(Optional.ofNullable(quoteItems)
1 ✔
295
                .orElseGet(List::of)
1 ✔
296
                .stream()
1 ✔
297
                .filter(Objects::nonNull)
1 ✔
298
                .filter(item -> QuoteStateTypeVO.ACCEPTED.getValue().equals(item.getState()))
1 ✔
299
                .filter(item -> !QUOTE_DELETE_ACTION.equals(item.getAction()))
1 ✔
300
                .map(QuoteItemVO::getProductOffering)
1 ✔
301
                .filter(Objects::nonNull)
1 ✔
302
                .map(org.fiware.iam.tmforum.quote.model.ProductOfferingRefVO::getId)
1 ✔
303
                .filter(Objects::nonNull)
1 ✔
304
                .map(this::getAuthorizationPolicyFromOffer)
1 ✔
305
                .toList());
1 ✔
306
    }
307

308
    private List<Map<String, Object>> getAuthorizationPolicyFromPSC(List<ProductSpecificationCharacteristicVO> pscList) {
309
        return getAuthorizationPolicyFrom(CharacteristicValues.ofProductSpecification(pscList));
×
310
    }
311

312
    /**
313
     * Read the authorization policies from already normalized characteristics.
314
     * <p>
315
     * Only the first matching characteristic is read, which is the behaviour every writer in the data
316
     * space currently relies on.
317
     *
318
     * @param characteristics the characteristics of one or more specifications
319
     * @return the configured policies, empty if none is configured
320
     */
321
    private List<Map<String, Object>> getAuthorizationPolicyFrom(
322
            List<CharacteristicValues.Characteristic> characteristics) {
323
        return CharacteristicValues.byValueType(characteristics, AUTHORIZATION_POLICY_KEY)
1 ✔
324
                .map(characteristic -> CharacteristicValues.flatten(objectMapper, characteristic, POLICY_TYPE))
1 ✔
325
                .orElseGet(List::of);
1 ✔
326
    }
327

328
    private static PolicyConfig emptyConfig() {
329
        return new PolicyConfig(new ContractManagement(true), List.of());
1 ✔
330
    }
331

332
    /**
333
     * Build the exception for a configuration that is referenced but cannot be read. It is not logged
334
     * here: the order handler logs it once, together with the order it belongs to.
335
     *
336
     * @param reason  the failure reason
337
     * @param message what could not be resolved
338
     * @param cause   the failed call, if any
339
     * @return the exception to raise
340
     */
341
    private static TMForumException unresolvableReference(FailureReason reason, String message, @Nullable Throwable cause) {
342
        return new TMForumException(reason, message, cause);
1 ✔
343
    }
344

345
    /**
346
     * The authorization policies configured for one offering, together with the contract-management
347
     * responsible for enforcing them.
348
     *
349
     * @param contractManagement the responsible contract-management, local unless the provider declares one
350
     * @param policies           the configured ODRL policies, possibly empty
351
     */
352
    public record PolicyConfig(ContractManagement contractManagement, List<Map<String, Object>> policies) {
1 ✔
353
    }
354
}
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