diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/NotificationService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/NotificationService.java index a74239e6..5f902143 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/NotificationService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/NotificationService.java @@ -25,6 +25,7 @@ import org.springframework.stereotype.Service; import javax.xml.datatype.XMLGregorianCalendar; +import java.util.List; @Service public interface NotificationService { @@ -38,4 +39,16 @@ void notify(RetailCustomerEntity retailCustomer, XMLGregorianCalendar startDate, void notify(ApplicationInformationEntity applicationInformation, Long bulkId); + /** + * Send an ad-hoc ESPI {@code BatchList} of resource URLs to a Third Party notification endpoint, + * synchronously (#177). Unlike the fire-and-forget {@code notify(...)} methods, + * this surfaces failures to the caller so an admin UI can report success/error. + * + * @param thirdPartyNotificationUri the Third Party endpoint to POST the BatchList to + * @param resourceUris the resource URLs to include; blank entries are ignored + * @throws IllegalArgumentException if the URI is blank or no usable resource URL is supplied + * @throws RuntimeException if the POST fails (connection/HTTP error) + */ + void notifyBatchList(String thirdPartyNotificationUri, List resourceUris); + } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImpl.java index f96e11fe..8b00b531 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImpl.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImpl.java @@ -260,6 +260,33 @@ public void notifyAllNeed() { } + @Override + public void notifyBatchList(String thirdPartyNotificationUri, List resourceUris) { + if (thirdPartyNotificationUri == null || thirdPartyNotificationUri.isBlank()) { + throw new IllegalArgumentException("Third Party notification URL is required"); + } + List resources = (resourceUris == null ? List.of() : resourceUris).stream() + .filter(uri -> uri != null && !uri.isBlank()) + .map(String::trim) + .toList(); + if (resources.isEmpty()) { + throw new IllegalArgumentException("At least one resource URL is required"); + } + + // Synchronous send so the caller (admin UI) gets the outcome. retrieve() throws on a 4xx/5xx + // response and the underlying client throws on a connection failure. + String xml = BatchListXmlCodec.marshal(new BatchListDto(resources)); + restClient.post() + .uri(thirdPartyNotificationUri) + .contentType(MediaType.APPLICATION_ATOM_XML) + .body(xml) + .retrieve() + .toBodilessEntity(); + if (log.isInfoEnabled()) { + log.info("notifyBatchList: POSTed {} resource(s) to {}", resources.size(), thirdPartyNotificationUri); + } + } + @Override public void notify(ApplicationInformationEntity applicationInformation, Long bulkId) { diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImplWireContractTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImplWireContractTest.java index 456c71d8..d3750f3f 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImplWireContractTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImplWireContractTest.java @@ -126,4 +126,52 @@ void notifyPostsConformantBatchList() throws Exception { .satisfies(uri -> assertThat(EspiBatchUri.subscriptionId(uri)).contains(subscriptionId.toString())); } + + @Test + @DisplayName("notifyBatchList() POSTs all supplied resource URLs as one BatchList (#177)") + void notifyBatchListPostsAllResources() throws Exception { + String notifyUri = "http://" + stub.getAddress().getHostString() + + ":" + stub.getAddress().getPort() + "/ThirdParty/espi/1_1/Notification"; + + java.util.List urls = java.util.List.of( + DC_RESOURCE_BASE + "/ApplicationInformation/app-1", + DC_RESOURCE_BASE + "/Authorization", + DC_RESOURCE_BASE + "/Authorization/auth-1", + DC_RESOURCE_BASE + "/Subscription/sub-1"); + + NotificationServiceImpl service = + new NotificationServiceImpl(RestClient.builder(), null, null, null); + // Blank entries are dropped; the four real URLs must all be carried. + java.util.List withBlank = new java.util.ArrayList<>(urls); + withBlank.add(" "); + service.notifyBatchList(notifyUri, withBlank); + + assertThat(received.await(5, TimeUnit.SECONDS)) + .as("TP stub must have received the notification POST").isTrue(); + assertThat(capturedMethod.get()).isEqualTo("POST"); + assertThat(capturedContentType.get()).startsWith("application/atom+xml"); + + BatchListDto sent = BatchListXmlCodec.unmarshal(capturedBody.get()); + assertThat(sent.getResources()).containsExactlyElementsOf(urls); + } + + @Test + @DisplayName("notifyBatchList() rejects a blank notification URI (#177)") + void notifyBatchListRejectsBlankUri() { + NotificationServiceImpl service = + new NotificationServiceImpl(RestClient.builder(), null, null, null); + assertThat(org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> service.notifyBatchList(" ", java.util.List.of("http://x/Subscription/1")))) + .hasMessageContaining("notification URL"); + } + + @Test + @DisplayName("notifyBatchList() rejects an empty resource list (#177)") + void notifyBatchListRejectsEmptyResources() { + NotificationServiceImpl service = + new NotificationServiceImpl(RestClient.builder(), null, null, null); + assertThat(org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> service.notifyBatchList("http://tp/Notification", java.util.List.of(" ")))) + .hasMessageContaining("resource URL"); + } } diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/BatchNotificationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/BatchNotificationController.java new file mode 100644 index 00000000..b6407f61 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/custodian/BatchNotificationController.java @@ -0,0 +1,138 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://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. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.web.custodian; + +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.service.ApplicationInformationService; +import org.greenbuttonalliance.espi.common.service.NotificationService; +import org.springframework.core.env.Environment; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.List; +import java.util.stream.Stream; + +/** + * Custodian "Notify Third Party" page (#177). Lets an admin compose and send an ESPI + * {@code BatchList} (Atom) of resource URLs — ApplicationInformation, Authorization feed, + * Authorization entry, Subscription — to a Third Party notification endpoint, reusing the #158 + * notification contract via {@link NotificationService#notifyBatchList}. + */ +@Controller +@PreAuthorize("hasRole('ROLE_CUSTODIAN')") +public class BatchNotificationController { + + private static final String DEFAULT_TP_NOTIFY_URI = + "http://localhost:8082/ThirdParty/espi/1_1/Notification"; + + private final NotificationService notificationService; + private final ApplicationInformationService applicationInformationService; + private final Environment environment; + + public BatchNotificationController(NotificationService notificationService, + ApplicationInformationService applicationInformationService, + Environment environment) { + this.notificationService = notificationService; + this.applicationInformationService = applicationInformationService; + this.environment = environment; + } + + @GetMapping("/custodian/notifications") + public String form(Model model) { + List apps = applicationInformationService.findAll(); + if (!model.containsAttribute("notifyForm")) { + model.addAttribute("notifyForm", defaultForm(apps)); + } + // Notification URLs the admin can pick from (the registered third parties' notify endpoints). + List notifyUris = apps.stream() + .map(ApplicationInformationEntity::getThirdPartyNotifyUri) + .filter(u -> u != null && !u.isBlank()) + .distinct() + .toList(); + model.addAttribute("notifyUris", notifyUris); + return "custodian/notifications"; + } + + @PostMapping("/custodian/notifications/send") + public String send(@ModelAttribute("notifyForm") NotifyForm form, RedirectAttributes redirectAttributes) { + List resources = Stream.of( + form.getApplicationInformationUrl(), + form.getAuthorizationFeedUrl(), + form.getAuthorizationEntryUrl(), + form.getSubscriptionUrl()) + .filter(u -> u != null && !u.isBlank()) + .toList(); + try { + notificationService.notifyBatchList(form.getNotificationUri(), resources); + redirectAttributes.addFlashAttribute("message", + "BatchList sent to " + form.getNotificationUri() + " (" + resources.size() + " resource(s))."); + redirectAttributes.addFlashAttribute("messageType", "success"); + } + catch (Exception e) { + redirectAttributes.addFlashAttribute("message", "Failed to send BatchList: " + e.getMessage()); + redirectAttributes.addFlashAttribute("messageType", "danger"); + } + // Preserve what the admin typed so they can correct and resend. + redirectAttributes.addFlashAttribute("notifyForm", form); + return "redirect:/custodian/notifications"; + } + + private NotifyForm defaultForm(List apps) { + String base = environment.getProperty("espi.datacustodian.base-url", + "http://localhost:8081/DataCustodian"); + String resourceBase = base + "/espi/1_1/resource"; + NotifyForm f = new NotifyForm(); + f.setNotificationUri(apps.stream() + .map(ApplicationInformationEntity::getThirdPartyNotifyUri) + .filter(u -> u != null && !u.isBlank()) + .findFirst() + .orElse(DEFAULT_TP_NOTIFY_URI)); + f.setApplicationInformationUrl(resourceBase + "/ApplicationInformation/{applicationInformationId}"); + f.setAuthorizationFeedUrl(resourceBase + "/Authorization"); + f.setAuthorizationEntryUrl(resourceBase + "/Authorization/{authorizationId}"); + f.setSubscriptionUrl(resourceBase + "/Subscription/{subscriptionId}"); + return f; + } + + /** Backing form for the notify page. */ + public static class NotifyForm { + private String notificationUri; + private String applicationInformationUrl; + private String authorizationFeedUrl; + private String authorizationEntryUrl; + private String subscriptionUrl; + + public String getNotificationUri() { return notificationUri; } + public void setNotificationUri(String notificationUri) { this.notificationUri = notificationUri; } + public String getApplicationInformationUrl() { return applicationInformationUrl; } + public void setApplicationInformationUrl(String v) { this.applicationInformationUrl = v; } + public String getAuthorizationFeedUrl() { return authorizationFeedUrl; } + public void setAuthorizationFeedUrl(String v) { this.authorizationFeedUrl = v; } + public String getAuthorizationEntryUrl() { return authorizationEntryUrl; } + public void setAuthorizationEntryUrl(String v) { this.authorizationEntryUrl = v; } + public String getSubscriptionUrl() { return subscriptionUrl; } + public void setSubscriptionUrl(String v) { this.subscriptionUrl = v; } + } +} diff --git a/openespi-datacustodian/src/main/resources/templates/custodian/home.html b/openespi-datacustodian/src/main/resources/templates/custodian/home.html index 4b5703f0..f6a03668 100644 --- a/openespi-datacustodian/src/main/resources/templates/custodian/home.html +++ b/openespi-datacustodian/src/main/resources/templates/custodian/home.html @@ -51,6 +51,17 @@
Data Upload
+
+
+
+ +
Notify Third Party
+

Send an ESPI BatchList of resource URLs to a third party.

+ Notify +
+
+
+
diff --git a/openespi-datacustodian/src/main/resources/templates/custodian/notifications.html b/openespi-datacustodian/src/main/resources/templates/custodian/notifications.html new file mode 100644 index 00000000..4c484270 --- /dev/null +++ b/openespi-datacustodian/src/main/resources/templates/custodian/notifications.html @@ -0,0 +1,72 @@ + + + + Custodian Portal - Notify Third Party + + + + + +
+

Notify Third Party

+

+ Compose an ESPI BatchList of resource URLs and POST it to a Third Party's + notification endpoint. Leave a field blank to omit that resource. +

+ +
result
+ +
+
+
+
+ + + + + +
Registered third-party endpoints are suggested; you may edit.
+
+ +
+
BatchList resources
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + Cancel +
+
+
+
+ +
+
+
+ + diff --git a/openespi-datacustodian/src/main/resources/templates/fragments/layout.html b/openespi-datacustodian/src/main/resources/templates/fragments/layout.html index 05a9510e..5e7750ea 100644 --- a/openespi-datacustodian/src/main/resources/templates/fragments/layout.html +++ b/openespi-datacustodian/src/main/resources/templates/fragments/layout.html @@ -143,6 +143,9 @@ + diff --git a/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/config/SecurityConfiguration.java b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/config/SecurityConfiguration.java index 30667cab..2739501a 100644 --- a/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/config/SecurityConfiguration.java +++ b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/config/SecurityConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -58,6 +59,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/", "/home", "/login", "/css/**", "/js/**", "/images/**").permitAll() + // ESPI notification receipt: the Data Custodian POSTs a BatchList here without an + // OAuth token — this endpoint is secured at the transport layer (TLS), not with an + // OAuth access token. (The Third Party, as an OAuth client, presents its access token + // only when it later fetches the source URLs carried in the BatchList.) + .requestMatchers(HttpMethod.POST, "/espi/1_1/Notification").permitAll() .requestMatchers("/h2-console/**").hasRole("ADMIN") .anyRequest().authenticated() ) diff --git a/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationController.java b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationController.java index 4b1ac1e6..0a93844c 100644 --- a/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationController.java +++ b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationController.java @@ -19,30 +19,19 @@ package org.greenbuttonalliance.espi.thirdparty.web; -import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; import org.greenbuttonalliance.espi.common.domain.usage.BatchListEntity; -import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; import org.greenbuttonalliance.espi.common.dto.usage.BatchListDto; -// // TODO: Find correct Routes import import org.greenbuttonalliance.espi.common.service.*; import org.greenbuttonalliance.espi.common.xml.BatchListXmlCodec; import org.greenbuttonalliance.espi.thirdparty.service.WebClientService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.http.*; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; - -import java.io.IOException; @Controller public class NotificationController extends BaseController { @@ -73,15 +62,22 @@ public class NotificationController extends BaseController { @PostMapping("/espi/1_1/Notification") // TODO: Use Routes.THIRD_PARTY_NOTIFICATION when available public ResponseEntity notification(@RequestBody String xmlPayload) { + BatchListDto batchList; try { // Parse the ESPI BatchList through the single canonical codec (#158) — the wire type is // the JAXB DTO, not the JPA entity (per the project's strict JAXB/JPA separation rule). - BatchListDto batchList = BatchListXmlCodec.unmarshal(xmlPayload); + batchList = BatchListXmlCodec.unmarshal(xmlPayload); + } catch (Exception e) { + // A payload we cannot parse is a bad request, not a server fault. + logger.warn("Notification: unparseable BatchList payload", e); + return ResponseEntity.badRequest().build(); + } + try { batchListService.save(new BatchListEntity(batchList.getResources())); for (String resourceUri : batchList.getResources()) { - doImportAsynchronously(resourceUri); + importResource(resourceUri); } logger.info("Successfully processed notification with {} resources", batchList.getResources().size()); @@ -92,119 +88,43 @@ public ResponseEntity notification(@RequestBody String xmlPayload) { } } - @Async - protected void doImportAsynchronously(String subscriptionUri) { - - // The import related to a subscription is performed here (in a separate - // thread) - // This must be provably secure b/c the access_token is visible here - String threadName = Thread.currentThread().getName(); - logger.debug("Start Asynchronous Input: {}: {}", threadName, subscriptionUri); - - String resourceUri = subscriptionUri; - String accessToken = ""; - AuthorizationEntity authorization = null; - RetailCustomerEntity retailCustomer = null; - - if (subscriptionUri.indexOf("?") > -1) { // Does message contain a query - // element - resourceUri = subscriptionUri.substring(0, - subscriptionUri.indexOf("?")); // Yes, remove the query - // element + /** + * Pull and import a single source URL advertised in the received BatchList. + * + *

ESPI flow: each {@code } element is a URL; the Third Party — acting as an OAuth + * client — performs an authenticated {@code GET} on that URL to retrieve the resource data from + * the Data Custodian, then persists it. (The inbound notification POST itself is not OAuth- + * protected — it is secured at the transport layer, TLS; the access token is required only on + * this outbound fetch.)

+ * + *

The GET is issued through the OAuth2-enabled {@link WebClient}, which attaches the access + * token of the authorized client. Two pieces complete a fully-functional fetch: selecting the + * correct OAuth token for the resource's Authorization (the unattended-notification token source, + * #146) and unmarshalling/persisting the returned ESPI payload (the import pipeline, #89). A fetch + * failure for one resource is logged and does not abort the others.

+ * + *

(The legacy {@code sftp://} delivery branch has been removed: the current ESPI standard no + * longer permits SFTP notification delivery.)

+ */ + protected void importResource(String resourceUri) { + try { + // As an OAuth client, GET the source URL with an access token (attached by the + // OAuth2-enabled WebClient) to obtain the resource data. + String payload = webClient.get() + .uri(resourceUri) + .retrieve() + .bodyToMono(String.class) + .block(); + + // TODO(#89): unmarshal the returned ESPI Atom payload and persist the resources. + logger.info("Notification: fetched resource {} ({} bytes) for import", + resourceUri, payload == null ? 0 : payload.length()); } - if (resourceUri.contains("sftp://")) { - - try { - String command = "sftp mget " - + resourceUri.substring(resourceUri.indexOf("sftp://")); - - logger.info("[Manage] Restricted Management Interface"); - logger.info("[Manage] Request: {}", command); - - Process p = Runtime.getRuntime().exec(command); - - // the sftp script will get the file and make a RESTful api call - // to add it into the workspace. - - } catch (IOException e1) { - logger.error("**** [Manage] IO Error: {}", e1.toString()); - - } catch (Exception e) { - logger.error("**** [Manage] Error: {}", e.toString()); - } - - } else { - try { - if ((resourceUri.contains("/Batch/Bulk")) - || (resourceUri.contains("/Authorization"))) { - // mutate the resourceUri to be of the form .../Batch/Bulk - resourceUri = (resourceUri.substring( - 0, - resourceUri.indexOf("/resource/") - + "/resource/".length()) - .concat("Batch/Bulk")); - - } else { - if (resourceUri.contains("/Subscription")) { - // mutate the resourceUri for the form - // /Subscription/{subscriptionId}/** - String temp = resourceUri.substring(resourceUri - .indexOf("/Subscription/") - + "/Subscription/".length()); - if (temp.contains("/")) { - resourceUri = resourceUri.substring( - 0, - resourceUri.indexOf("/Subscription") - + "/Subscription".length()).concat( - temp.substring(0, temp.indexOf("/"))); - } - } - } - - // Authorization x = resourceService.findById(2L, - // AuthorizationEntity.class); - - // if (x.getResourceURI().equals(resourceUri)) { - // logger.debug("ResourceURIs Equal: {}", resourceUri); - // } else { - // logger.debug("ResourceURIs Not Equal: {}", resourceUri); - // } - // authorization = resourceService.findByResourceUri(resourceUri, - // AuthorizationEntity.class); - // retailCustomer = authorization.getRetailCustomer(); - // accessToken = authorization.getAccessToken(); - - try { - // Create authenticated WebClient for resource access - WebClient authenticatedClient = webClientService.createAuthenticatedWebClient(accessToken); - - // Get the subscription data using WebClient - String responseBody = webClientService.getForObject( - authenticatedClient, subscriptionUri, String.class); - - // if (responseBody != null) { - // // Import data into the repository - // ByteArrayInputStream bs = new ByteArrayInputStream(responseBody.getBytes()); - // importService.importData(bs, retailCustomer.getId()); - // logger.debug("Successfully imported data from subscription: {}", subscriptionUri); - // } else { - // logger.warn("No data received from subscription: {}", subscriptionUri); - // } - - } catch (WebClientResponseException e) { - logger.error("HTTP error during subscription import: {} - {}", - e.getStatusCode(), e.getResponseBodyAsString()); - } catch (Exception e) { - logger.error("Error during asynchronous import from subscription: {}", subscriptionUri, e); - } - - } catch (EmptyResultDataAccessException e) { - // No authorization found - data will be imported later when authorization is available - logger.info("No authorization found for resource URI: {} - will import later", resourceUri); - } + catch (Exception e) { + // e.g. no OAuth token resolvable for this resource yet (#146), or the DC returns 401/4xx. + logger.warn("Notification: could not fetch resource {} for import: {}", + resourceUri, e.getMessage()); } - - logger.debug("Asynchronous import completed for thread {}: {}", threadName, resourceUri); } public void setBatchListService(BatchListService batchListService) {