Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.springframework.stereotype.Service;

import javax.xml.datatype.XMLGregorianCalendar;
import java.util.List;

@Service
public interface NotificationService {
Expand All @@ -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,
* <strong>synchronously</strong> (#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<String> resourceUris);

}
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,33 @@ public void notifyAllNeed() {
}


@Override
public void notifyBatchList(String thirdPartyNotificationUri, List<String> resourceUris) {
if (thirdPartyNotificationUri == null || thirdPartyNotificationUri.isBlank()) {
throw new IllegalArgumentException("Third Party notification URL is required");
}
List<String> resources = (resourceUris == null ? List.<String>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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<String> 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");
}
}
Original file line number Diff line number Diff line change
@@ -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<ApplicationInformationEntity> 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<String> 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<String> 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<ApplicationInformationEntity> 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; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ <h5 class="card-title mt-3">Data Upload</h5>
</div>
</div>

<div class="col-lg-3 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body text-center d-flex flex-column">
<i class="bi bi-send fs-1 text-danger"></i>
<h5 class="card-title mt-3">Notify Third Party</h5>
<p class="card-text flex-grow-1">Send an ESPI BatchList of resource URLs to a third party.</p>
<a th:href="@{/custodian/notifications}" class="btn btn-danger">Notify</a>
</div>
</div>
</div>

<div class="col-lg-3 col-md-6 mb-4">
<div class="card h-100">
<div class="card-body text-center d-flex flex-column">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head}">
<title>Custodian Portal - Notify Third Party</title>
</head>

<body>
<nav th:replace="~{fragments/layout :: custodianHeader}"></nav>

<div class="container">
<h2 class="mt-4">Notify Third Party</h2>
<p class="text-muted">
Compose an ESPI <code>BatchList</code> of resource URLs and POST it to a Third Party's
notification endpoint. Leave a field blank to omit that resource.
</p>

<div th:if="${message}" class="alert"
th:classappend="${messageType == 'success'} ? 'alert-success' : 'alert-danger'"
th:text="${message}">result</div>

<div class="card mt-3">
<div class="card-body">
<form th:action="@{/custodian/notifications/send}" th:object="${notifyForm}" method="post">
<div class="mb-3">
<label for="notificationUri" class="form-label">Third Party notification URL</label>
<input type="url" id="notificationUri" class="form-control"
th:field="*{notificationUri}" list="notifyUriOptions" required/>
<datalist id="notifyUriOptions">
<option th:each="uri : ${notifyUris}" th:value="${uri}"></option>
</datalist>
<div class="form-text">Registered third-party endpoints are suggested; you may edit.</div>
</div>

<hr>
<h6 class="text-muted">BatchList resources</h6>

<div class="mb-3">
<label for="applicationInformationUrl" class="form-label">ApplicationInformation URL</label>
<input type="text" id="applicationInformationUrl" class="form-control"
th:field="*{applicationInformationUrl}"/>
</div>
<div class="mb-3">
<label for="authorizationFeedUrl" class="form-label">Authorization feed URL</label>
<input type="text" id="authorizationFeedUrl" class="form-control"
th:field="*{authorizationFeedUrl}"/>
</div>
<div class="mb-3">
<label for="authorizationEntryUrl" class="form-label">Authorization entry URL</label>
<input type="text" id="authorizationEntryUrl" class="form-control"
th:field="*{authorizationEntryUrl}"/>
</div>
<div class="mb-3">
<label for="subscriptionUrl" class="form-label">Subscription URL</label>
<input type="text" id="subscriptionUrl" class="form-control"
th:field="*{subscriptionUrl}"/>
</div>

<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-send"></i> Send BatchList
</button>
<a th:href="@{/custodian/home}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>

<hr class="my-5">
<footer th:replace="~{fragments/layout :: footer}"></footer>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@
<li class="nav-item">
<a class="nav-link" th:href="@{/custodian/upload}">Data Upload</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/custodian/notifications}">Notify Third Party</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/custodian/settings}">Settings</a>
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
)
Expand Down
Loading
Loading