From 19f8f4abc89390a6c762ca1731f2a64ec7b8adcf Mon Sep 17 00:00:00 2001 From: ds-awahl Date: Thu, 10 Oct 2024 14:44:35 +0200 Subject: [PATCH] chore: [TRX-105] secure edc callbacks (#19) (cherry picked from commit ad88b53f9629fe3fd7d2fe024e511c8422fdbb7e) --- CHANGELOG.md | 3 + .../irs/edc/client/asset/EdcAssetService.java | 47 ++++-- .../EdcTechnicalServiceAuthentication.java | 32 ++++ .../edc/client/asset/EdcAssetServiceTest.java | 146 +++++++++++------- 4 files changed, 157 insertions(+), 71 deletions(-) create mode 100644 irs-edc-client/src/main/java/org/eclipse/tractusx/irs/edc/client/model/EdcTechnicalServiceAuthentication.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 596a50569b..939be8c908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ _**For better traceability add the corresponding GitHub issue number in each cha ## [Unreleased] +### Added +- Added api key authentication for edc notification requests + ### Changed - Added the discovery type configurable, with a default value of bpnl in (ConnectorEndpointsService) (#12) diff --git a/irs-edc-client/src/main/java/org/eclipse/tractusx/irs/edc/client/asset/EdcAssetService.java b/irs-edc-client/src/main/java/org/eclipse/tractusx/irs/edc/client/asset/EdcAssetService.java index 5b9b860d8e..c0b4cc84ea 100644 --- a/irs-edc-client/src/main/java/org/eclipse/tractusx/irs/edc/client/asset/EdcAssetService.java +++ b/irs-edc-client/src/main/java/org/eclipse/tractusx/irs/edc/client/asset/EdcAssetService.java @@ -39,6 +39,7 @@ import org.eclipse.tractusx.irs.edc.client.asset.model.exception.DeleteEdcAssetException; import org.eclipse.tractusx.irs.edc.client.asset.model.exception.EdcAssetAlreadyExistsException; import org.eclipse.tractusx.irs.edc.client.configuration.JsonLdConfiguration; +import org.eclipse.tractusx.irs.edc.client.model.EdcTechnicalServiceAuthentication; import org.eclipse.tractusx.irs.edc.client.transformer.EdcTransformer; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; @@ -64,6 +65,7 @@ public class EdcAssetService { private static final String ASSET_DATA_ADDRESS_PROXY_BODY = NAMESPACE_EDC + "proxyBody"; private static final String ASSET_DATA_ADDRESS_PROXY_PATH = NAMESPACE_EDC + "proxyPath"; private static final String ASSET_DATA_ADDRESS_PROXY_QUERY_PARAMS = NAMESPACE_EDC + "proxyQueryParams"; + private static final String ASSET_DATA_ADDRESS_TECHNICAL_SERVICE_API_KEY = "header:x-technical-service-key"; private static final String ASSET_DATA_ADDRESS_METHOD = NAMESPACE_EDC + "method"; private static final String ASSET_PROPERTY_DESCRIPTION = NAMESPACE_EDC + "description"; private static final String ASSET_PROPERTY_CONTENT_TYPE = NAMESPACE_EDC + "contenttype"; @@ -85,16 +87,17 @@ public class EdcAssetService { private final RestTemplate restTemplate; public String createNotificationAsset(final String baseUrl, final String assetName, - final NotificationMethod notificationMethod, final NotificationType notificationType) + final NotificationMethod notificationMethod, final NotificationType notificationType, final EdcTechnicalServiceAuthentication edcTechnicalServiceAuthentication) throws CreateEdcAssetException { final Notification notification = Notification.toNotification(notificationMethod, notificationType); - final Asset request = createNotificationAssetRequest(assetName, baseUrl, notification); + final Asset request = createNotificationAssetRequest(assetName, baseUrl, notification, + edcTechnicalServiceAuthentication); return sendRequest(request); } public String createNotificationAsset(final String baseUrl, final String assetName, final Notification notification) throws CreateEdcAssetException { - final Asset request = createNotificationAssetRequest(assetName, baseUrl, notification); + final Asset request = createNotificationAssetRequest(assetName, baseUrl, notification, null); return sendRequest(request); } @@ -143,24 +146,30 @@ public void deleteAsset(final String assetId) throws DeleteEdcAssetException { } private Asset createNotificationAssetRequest(final String assetName, final String baseUrl, - final Notification notification) { + final Notification notification, final EdcTechnicalServiceAuthentication edcTechnicalServiceAuthentication) { final String assetId = UUID.randomUUID().toString(); final Map properties = Map.of(ASSET_PROPERTY_DESCRIPTION, assetName, ASSET_PROPERTY_CONTENT_TYPE, DEFAULT_CONTENT_TYPE, ASSET_PROPERTY_POLICY_ID, DEFAULT_POLICY_ID, ASSET_PROPERTY_COMMON_VERSION_KEY, ASSET_PROPERTY_NOTIFICATION_VERSION, ASSET_PROPERTY_DCAT_TYPE, Map.of("@id", JsonLdConfiguration.NAMESPACE_CX_TAXONOMY + notification.getValue())); - final DataAddress dataAddress = DataAddress.Builder.newInstance() - .type(DATA_ADDRESS_TYPE_HTTP_DATA) - .property(EDC_DATA_ADDRESS_TYPE_PROPERTY, - DATA_ADDRESS_TYPE_HTTP_DATA) - .property(ASSET_DATA_ADDRESS_BASE_URL, baseUrl) - .property(ASSET_DATA_ADDRESS_PROXY_METHOD, - Boolean.TRUE.toString()) - .property(ASSET_DATA_ADDRESS_PROXY_BODY, - Boolean.TRUE.toString()) - .property(ASSET_DATA_ADDRESS_METHOD, DEFAULT_METHOD) - .build(); + final DataAddress.Builder dataAddressBuilder = DataAddress.Builder.newInstance() + .type(DATA_ADDRESS_TYPE_HTTP_DATA) // Address type HTTP + .property(EDC_DATA_ADDRESS_TYPE_PROPERTY, + DATA_ADDRESS_TYPE_HTTP_DATA) // Address type property + .property(ASSET_DATA_ADDRESS_BASE_URL, + baseUrl) // Base URL + .property(ASSET_DATA_ADDRESS_PROXY_METHOD, + Boolean.TRUE.toString()) // Enable proxy method + .property(ASSET_DATA_ADDRESS_PROXY_BODY, + Boolean.TRUE.toString()) // Enable proxy body + .property(ASSET_DATA_ADDRESS_METHOD, + DEFAULT_METHOD); // Default method (e.g., GET, POST) + + enrichOptionalEdcApiAuthenticationToDataAddress(edcTechnicalServiceAuthentication, dataAddressBuilder); + + final DataAddress dataAddress = dataAddressBuilder.build(); + return Asset.Builder.newInstance() .id(assetId) .contentType("Asset") @@ -222,4 +231,12 @@ private Asset createSubmodelAssetRequest(final String assetId, final String base .dataAddress(dataAddress) .build(); } + + private static void enrichOptionalEdcApiAuthenticationToDataAddress( + final EdcTechnicalServiceAuthentication edcTechnicalServiceAuthentication, final DataAddress.Builder dataAddressBuilder) { + if (edcTechnicalServiceAuthentication != null && edcTechnicalServiceAuthentication.getTechnicalServiceApiKey() != null + && !edcTechnicalServiceAuthentication.getTechnicalServiceApiKey().isEmpty()) { + dataAddressBuilder.property(ASSET_DATA_ADDRESS_TECHNICAL_SERVICE_API_KEY, edcTechnicalServiceAuthentication.getTechnicalServiceApiKey()); + } + } } diff --git a/irs-edc-client/src/main/java/org/eclipse/tractusx/irs/edc/client/model/EdcTechnicalServiceAuthentication.java b/irs-edc-client/src/main/java/org/eclipse/tractusx/irs/edc/client/model/EdcTechnicalServiceAuthentication.java new file mode 100644 index 0000000000..4c53e38001 --- /dev/null +++ b/irs-edc-client/src/main/java/org/eclipse/tractusx/irs/edc/client/model/EdcTechnicalServiceAuthentication.java @@ -0,0 +1,32 @@ +/******************************************************************************** + * Copyright (c) 2022,2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * Copyright (c) 2021,2024 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ +package org.eclipse.tractusx.irs.edc.client.model; + +import lombok.Builder; +import lombok.Data; + +/** + * EDC Technical Service API authentication. + */ +@Builder +@Data +public class EdcTechnicalServiceAuthentication { + private String technicalServiceApiKey; +} diff --git a/irs-edc-client/src/test/java/org/eclipse/tractusx/irs/edc/client/asset/EdcAssetServiceTest.java b/irs-edc-client/src/test/java/org/eclipse/tractusx/irs/edc/client/asset/EdcAssetServiceTest.java index 8b0c29aed8..d9bc64152d 100644 --- a/irs-edc-client/src/test/java/org/eclipse/tractusx/irs/edc/client/asset/EdcAssetServiceTest.java +++ b/irs-edc-client/src/test/java/org/eclipse/tractusx/irs/edc/client/asset/EdcAssetServiceTest.java @@ -51,6 +51,7 @@ import org.eclipse.tractusx.irs.edc.client.asset.model.exception.CreateEdcAssetException; import org.eclipse.tractusx.irs.edc.client.asset.model.exception.DeleteEdcAssetException; import org.eclipse.tractusx.irs.edc.client.asset.model.exception.EdcAssetAlreadyExistsException; +import org.eclipse.tractusx.irs.edc.client.model.EdcTechnicalServiceAuthentication; import org.eclipse.tractusx.irs.edc.client.transformer.EdcTransformer; import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; @@ -83,7 +84,7 @@ class EdcAssetServiceTest { @BeforeEach void setUp() { - TitaniumJsonLd jsonLd = new TitaniumJsonLd(new ConsoleMonitor()); + final TitaniumJsonLd jsonLd = new TitaniumJsonLd(new ConsoleMonitor()); jsonLd.registerNamespace("odrl", "http://www.w3.org/ns/odrl/2/"); jsonLd.registerNamespace("dct", "http://purl.org/dc/terms/"); jsonLd.registerNamespace("tx", "https://w3id.org/tractusx/v0.0.1/ns/"); @@ -99,29 +100,29 @@ void setUp() { @Test void testAssetCreateRequestStructure() throws JSONException { - Map properties = Map.of("https://w3id.org/edc/v0.0.1/ns/description", + final Map properties = Map.of("https://w3id.org/edc/v0.0.1/ns/description", "endpoint to qualityinvestigation receive", "https://w3id.org/edc/v0.0.1/ns/contenttype", "application/json", "https://w3id.org/edc/v0.0.1/ns/policy-id", "use-eu", "https://w3id.org/edc/v0.0.1/ns/type", "receive", "https://w3id.org/edc/v0.0.1/ns/notificationtype", "qualityinvestigation", "https://w3id.org/edc/v0.0.1/ns/notificationmethod", "receive"); - DataAddress dataAddress = DataAddress.Builder.newInstance() - .type(DATA_ADDRESS_TYPE_HTTP_DATA) - .property("https://w3id.org/edc/v0.0.1/ns/type", "HttpData") - .property("https://w3id.org/edc/v0.0.1/ns/baseUrl", + final DataAddress dataAddress = DataAddress.Builder.newInstance() + .type(DATA_ADDRESS_TYPE_HTTP_DATA) + .property("https://w3id.org/edc/v0.0.1/ns/type", "HttpData") + .property("https://w3id.org/edc/v0.0.1/ns/baseUrl", "https://traceability.dev.demo.catena-x.net/api/qualitynotifications/receive") - .property("https://w3id.org/edc/v0.0.1/ns/proxyMethod", "true") - .property("https://w3id.org/edc/v0.0.1/ns/proxyBody", "true") - .property("https://w3id.org/edc/v0.0.1/ns/method", "POST") - .build(); - - Asset asset = Asset.Builder.newInstance() - .id("Asset1") - .contentType("Asset") - .properties(properties) - .dataAddress(dataAddress) - .build(); - JsonObject jsonObject = edcTransformer.transformAssetToJson(asset); + .property("https://w3id.org/edc/v0.0.1/ns/proxyMethod", "true") + .property("https://w3id.org/edc/v0.0.1/ns/proxyBody", "true") + .property("https://w3id.org/edc/v0.0.1/ns/method", "POST") + .build(); + + final Asset asset = Asset.Builder.newInstance() + .id("Asset1") + .contentType("Asset") + .properties(properties) + .dataAddress(dataAddress) + .build(); + final JsonObject jsonObject = edcTransformer.transformAssetToJson(asset); JSONAssert.assertEquals(""" { @@ -159,28 +160,28 @@ void testAssetCreateRequestStructure() throws JSONException { @Test void testRegistryAssetCreateRequestStructure() throws JSONException { - Map properties = Map.of("http://purl.org/dc/terms/type", + final Map properties = Map.of("http://purl.org/dc/terms/type", Map.of("@id", "https://w3id.org/catenax/taxonomy#DigitalTwinRegistry"), "https://w3id.org/catenax/ontology/common#version", "3.0", "https://w3id.org/edc/v0.0.1/ns/type", "data.core.digitalTwinRegistry"); - DataAddress dataAddress = DataAddress.Builder.newInstance() - .type(DATA_ADDRESS_TYPE_HTTP_DATA) - .property("https://w3id.org/edc/v0.0.1/ns/type", "HttpData") - .property("https://w3id.org/edc/v0.0.1/ns/baseUrl", + final DataAddress dataAddress = DataAddress.Builder.newInstance() + .type(DATA_ADDRESS_TYPE_HTTP_DATA) + .property("https://w3id.org/edc/v0.0.1/ns/type", "HttpData") + .property("https://w3id.org/edc/v0.0.1/ns/baseUrl", "https://test.dtr/registry") - .property("https://w3id.org/edc/v0.0.1/ns/proxyMethod", "true") - .property("https://w3id.org/edc/v0.0.1/ns/proxyBody", "true") - .property("https://w3id.org/edc/v0.0.1/ns/method", "POST") - .build(); - - Asset asset = Asset.Builder.newInstance() - .id("Asset1") - .contentType("Asset") - .properties(properties) - .dataAddress(dataAddress) - .build(); - JsonObject jsonObject = edcTransformer.transformAssetToJson(asset); + .property("https://w3id.org/edc/v0.0.1/ns/proxyMethod", "true") + .property("https://w3id.org/edc/v0.0.1/ns/proxyBody", "true") + .property("https://w3id.org/edc/v0.0.1/ns/method", "POST") + .build(); + + final Asset asset = Asset.Builder.newInstance() + .id("Asset1") + .contentType("Asset") + .properties(properties) + .dataAddress(dataAddress) + .build(); + final JsonObject jsonObject = edcTransformer.transformAssetToJson(asset); JSONAssert.assertEquals(""" { @@ -221,15 +222,15 @@ void givenCreateNotificationAsset_whenOk_ThenReturnCreatedAssetId() throws Creat when(edcConfiguration.getControlplane()).thenReturn(controlplaneConfig); when(controlplaneConfig.getEndpoint()).thenReturn(endpointConfig); when(endpointConfig.getAsset()).thenReturn(MANAGEMENT_ASSETS_PATH); - String baseUrl = "http://test.test"; - String assetName = "asset1"; - NotificationMethod notificationMethod = NotificationMethod.RECEIVE; - NotificationType notificationType = NotificationType.QUALITY_ALERT; + final String baseUrl = "http://test.test"; + final String assetName = "asset1"; + final NotificationMethod notificationMethod = NotificationMethod.RECEIVE; + final NotificationType notificationType = NotificationType.QUALITY_ALERT; when(restTemplate.postForEntity(any(String.class), any(String.class), any())).thenReturn( ResponseEntity.ok("test")); // when - String assetId = service.createNotificationAsset(baseUrl, assetName, notificationMethod, notificationType); + final String assetId = service.createNotificationAsset(baseUrl, assetName, notificationMethod, notificationType, null); // then assertThat(assetId).isNotBlank(); @@ -244,14 +245,14 @@ void givenCreateTaxoNotificationAsset_whenOk_ThenReturnCreatedAssetId() throws C when(edcConfiguration.getControlplane()).thenReturn(controlplaneConfig); when(controlplaneConfig.getEndpoint()).thenReturn(endpointConfig); when(endpointConfig.getAsset()).thenReturn(MANAGEMENT_ASSETS_PATH); - String baseUrl = "http://test.test"; - String assetName = "asset1"; - Notification updateQualityAlertNotification = Notification.UPDATE_QUALITY_ALERT_NOTIFICATION; + final String baseUrl = "http://test.test"; + final String assetName = "asset1"; + final Notification updateQualityAlertNotification = Notification.UPDATE_QUALITY_ALERT_NOTIFICATION; when(restTemplate.postForEntity(any(String.class), any(String.class), any())).thenReturn( ResponseEntity.ok("test")); // when - String assetId = service.createNotificationAsset(baseUrl, assetName, updateQualityAlertNotification); + final String assetId = service.createNotificationAsset(baseUrl, assetName, updateQualityAlertNotification); // then assertThat(assetId).isNotBlank(); @@ -273,13 +274,13 @@ void givenCreateDtrAsset_whenOk_ThenReturnCreatedAssetId() throws CreateEdcAsset when(edcConfiguration.getControlplane()).thenReturn(controlplaneConfig); when(controlplaneConfig.getEndpoint()).thenReturn(endpointConfig); when(endpointConfig.getAsset()).thenReturn(MANAGEMENT_ASSETS_PATH); - String baseUrl = "http://test.test"; - String assetName = "asset1"; + final String baseUrl = "http://test.test"; + final String assetName = "asset1"; when(restTemplate.postForEntity(any(String.class), any(String.class), any())).thenReturn( ResponseEntity.ok("test")); // when - String assetId = service.createDtrAsset(baseUrl, assetName); + final String assetId = service.createDtrAsset(baseUrl, assetName); // then assertThat(assetId).isNotBlank(); @@ -291,13 +292,13 @@ void givenCreateSubmodelAsset_whenOk_ThenReturnCreatedAssetId() throws CreateEdc when(edcConfiguration.getControlplane()).thenReturn(controlplaneConfig); when(controlplaneConfig.getEndpoint()).thenReturn(endpointConfig); when(endpointConfig.getAsset()).thenReturn(MANAGEMENT_ASSETS_PATH); - String baseUrl = "http://test.test"; - String assetName = "asset1"; + final String baseUrl = "http://test.test"; + final String assetName = "asset1"; when(restTemplate.postForEntity(any(String.class), any(String.class), any())).thenReturn( ResponseEntity.ok("test")); // when - String assetId = service.createSubmodelAsset(baseUrl, assetName); + final String assetId = service.createSubmodelAsset(baseUrl, assetName); // then assertThat(assetId).isNotBlank(); @@ -309,7 +310,7 @@ void givenDeleteAsset_whenOk_ThenReturnCreatedAssetId() throws DeleteEdcAssetExc when(edcConfiguration.getControlplane()).thenReturn(controlplaneConfig); when(controlplaneConfig.getEndpoint()).thenReturn(endpointConfig); when(endpointConfig.getAsset()).thenReturn(MANAGEMENT_ASSETS_PATH); - String assetId = "id"; + final String assetId = "id"; // when service.deleteAsset(assetId); @@ -324,8 +325,8 @@ void givenCreateDtrAsset_whenBadRequest_ThenThrowException() { when(edcConfiguration.getControlplane()).thenReturn(controlplaneConfig); when(controlplaneConfig.getEndpoint()).thenReturn(endpointConfig); when(endpointConfig.getAsset()).thenReturn(MANAGEMENT_ASSETS_PATH); - String baseUrl = "http://test.test"; - String assetName = "asset1"; + final String baseUrl = "http://test.test"; + final String assetName = "asset1"; doThrow(HttpClientErrorException.create("Surprise", HttpStatus.BAD_REQUEST, "", null, null, null)).when( restTemplate).postForEntity(any(String.class), any(String.class), any()); @@ -339,8 +340,8 @@ void givenCreateDtrAsset_whenConflict_ThenThrowException() { when(edcConfiguration.getControlplane()).thenReturn(controlplaneConfig); when(controlplaneConfig.getEndpoint()).thenReturn(endpointConfig); when(endpointConfig.getAsset()).thenReturn(MANAGEMENT_ASSETS_PATH); - String baseUrl = "http://test.test"; - String assetName = "asset1"; + final String baseUrl = "http://test.test"; + final String assetName = "asset1"; doThrow(HttpClientErrorException.create("Surprise", HttpStatus.CONFLICT, "", null, null, null)).when( restTemplate).postForEntity(any(String.class), any(String.class), any()); @@ -354,13 +355,46 @@ void givenDeleteAsset_whenTemplateException_ThenThrowException() { when(edcConfiguration.getControlplane()).thenReturn(controlplaneConfig); when(controlplaneConfig.getEndpoint()).thenReturn(endpointConfig); when(endpointConfig.getAsset()).thenReturn(MANAGEMENT_ASSETS_PATH); - String assetId = "id"; + final String assetId = "id"; doThrow(new RestClientException("Surprise")).when(restTemplate).delete(any(String.class)); // when/then assertThrows(DeleteEdcAssetException.class, () -> service.deleteAsset(assetId)); } + @Test + void givenCreateNotificationAssetIncludingAuthentication_whenOk_ThenReturnCreatedAssetId() throws CreateEdcAssetException { + // given + when(edcConfiguration.getControlplane()).thenReturn(controlplaneConfig); + when(controlplaneConfig.getEndpoint()).thenReturn(endpointConfig); + when(endpointConfig.getAsset()).thenReturn(MANAGEMENT_ASSETS_PATH); + final String baseUrl = "http://test.test"; + final String assetName = "asset1"; + final NotificationMethod notificationMethod = NotificationMethod.RECEIVE; + final NotificationType notificationType = NotificationType.QUALITY_ALERT; + when(restTemplate.postForEntity(any(String.class), any(String.class), any())).thenReturn( + ResponseEntity.ok("test")); + + final EdcTechnicalServiceAuthentication edcTechnicalServiceAuthentication = EdcTechnicalServiceAuthentication.builder() + .technicalServiceApiKey("apiKeyValue").build(); + + // when + final String assetId = service.createNotificationAsset(baseUrl, assetName, notificationMethod, notificationType, edcTechnicalServiceAuthentication); + + // then + assertThat(assetId).isNotBlank(); + final String expectedRequestPayload = expectedCreateNotificationAssetIncludingAuthenticationPayload(assetId, + Notification.RECEIVE_QUALITY_ALERT_NOTIFICATION); + verify(restTemplate, times(1)).postForEntity(MANAGEMENT_ASSETS_PATH, expectedRequestPayload, String.class); + } + + private static String expectedCreateNotificationAssetIncludingAuthenticationPayload(final String assetId, + final Notification notification) { + return """ + {"@id":"%s","@type":"edc:Asset","edc:properties":{"edc:policy-id":"use-eu","dct:type":{"@id":"https://w3id.org/catenax/taxonomy#%s"},"edc:description":"asset1","https://w3id.org/catenax/ontology/common#version":"1.2","edc:id":"%s","edc:contenttype":"application/json"},"edc:dataAddress":{"@type":"edc:DataAddress","edc:method":"POST","edc:type":"HttpData","edc:proxyMethod":"true","edc:proxyBody":"true","header:x-technical-service-key":"apiKeyValue","edc:baseUrl":"http://test.test"},"@context":{"odrl":"http://www.w3.org/ns/odrl/2/","dct":"http://purl.org/dc/terms/","tx":"https://w3id.org/tractusx/v0.0.1/ns/","edc":"https://w3id.org/edc/v0.0.1/ns/","dcat":"https://www.w3.org/ns/dcat/","dspace":"https://w3id.org/dspace/v0.8/","cx-policy":"https://w3id.org/catenax/policy/"}}""".formatted( + assetId, notification.getValue(), assetId); + } + ObjectMapper objectMapper() { final ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule());