diff --git a/contracts/README.md b/contracts/README.md index 1763df80..092a3b15 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,10 +1,11 @@ -# Cross-service wire contracts (AS ↔ DC) +# Cross-service wire contracts (AS ↔ DC, DC → TP) -Canonical JSON examples of the HTTP wire formats exchanged between the Authorization -Server and the Data Custodian. These files are the **single source of truth** both -services bind to in their own tests — a lightweight, framework-free consumer-driven -contract suited to standalone Spring Boot services (deployed independently on EC2), -**not** a Spring Cloud microservice mesh. +Canonical examples of the HTTP wire formats exchanged between the OpenESPI services: +the **Authorization Server ↔ Data Custodian** token/introspection format (JSON) and the +**Data Custodian → Third Party** notification format (XML). These files are the +**single source of truth** both services bind to in their own tests — a lightweight, +framework-free consumer-driven contract suited to standalone Spring Boot services +(deployed independently on EC2), **not** a Spring Cloud microservice mesh. ## How the contract is enforced (no Spring Cloud, no Pact broker) @@ -40,3 +41,23 @@ reproduce that standard shape for the two grant types: UUIDs/ids/tokens in the fixtures are illustrative; the contract is the **shape** — field names, the `Batch/{Subscription|Bulk|RetailCustomer}` URI forms, and the ESPI `FB=...` scope grammar. + +### DC → TP notification (BatchList, XML) + +When new/updated data is available, the Data Custodian POSTs an ESPI `BatchList` document to +the Third Party's registered `thirdPartyNotifyUri` (`POST {tpBase}/espi/1_1/Notification`, +`Content-Type: application/atom+xml`, `200 OK` on success). The body lists the canonical +`Batch/...` resource URIs the TP should fetch back. + +| File | Endpoint | Content-Type | +|------|----------|--------------| +| `notification-batchlist.xml` | `POST {tpBase}/espi/1_1/Notification` | `application/atom+xml` | + +- **Producer** (DC `NotificationServiceImpl`) builds the resource URI via `EspiBatchUri` + (`Batch/Subscription/{id}?published-min=…&published-max=…`) and serializes the `BatchListDto` + through `BatchListXmlCodec` — the single canonical BatchList wire codec. +- **Consumer** (TP `NotificationController`) parses the document through the same + `BatchListXmlCodec` and parses ids out of each resource URI via `EspiBatchUri`. + +The wire type is the JAXB-annotated `BatchListDto` (root ``, +repeating `` elements), never the JPA entity — per the project's JAXB/JPA separation rule. diff --git a/contracts/notification-batchlist.xml b/contracts/notification-batchlist.xml new file mode 100644 index 00000000..0f49a5c9 --- /dev/null +++ b/contracts/notification-batchlist.xml @@ -0,0 +1,4 @@ + + + https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/Subscription/503888?published-min=2025-01-01T00:00:00Z&published-max=2025-02-01T00:00:00Z + 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 d7b2fe8e..327b14ae 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 @@ -19,17 +19,21 @@ package org.greenbuttonalliance.espi.common.service.impl; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.greenbuttonalliance.espi.common.domain.usage.*; +import org.greenbuttonalliance.espi.common.dto.usage.BatchListDto; import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository; import org.greenbuttonalliance.espi.common.service.AuthorizationService; import org.greenbuttonalliance.espi.common.service.NotificationService; import org.greenbuttonalliance.espi.common.service.SubscriptionService; +import org.greenbuttonalliance.espi.common.uri.EspiBatchUri; +import org.greenbuttonalliance.espi.common.xml.BatchListXmlCodec; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; import javax.xml.datatype.XMLGregorianCalendar; import java.util.*; @@ -42,14 +46,39 @@ @Slf4j @Service @Transactional -@RequiredArgsConstructor public class NotificationServiceImpl implements NotificationService { - private final RestTemplate restTemplate; + private final RestClient restClient; private final AuthorizationRepository authorizationRepository; private final AuthorizationService authorizationService; private final SubscriptionService subscriptionService; + /** + * Production constructor. Builds a standalone {@link RestClient} for posting ESPI + * {@code BatchList} notifications to Third Party {@code thirdPartyNotifyUri} endpoints. + * Replaces the legacy {@code RestTemplate} (#158). + */ + @Autowired + public NotificationServiceImpl(AuthorizationRepository authorizationRepository, + AuthorizationService authorizationService, + SubscriptionService subscriptionService) { + this(RestClient.create(), authorizationRepository, authorizationService, subscriptionService); + } + + /** + * Test/seam constructor allowing a pre-configured {@link RestClient} (e.g. pointed at a + * stub receiver) to be injected. + */ + public NotificationServiceImpl(RestClient restClient, + AuthorizationRepository authorizationRepository, + AuthorizationService authorizationService, + SubscriptionService subscriptionService) { + this.restClient = restClient; + this.authorizationRepository = authorizationRepository; + this.authorizationService = authorizationService; + this.subscriptionService = subscriptionService; + } + @Override public void notify(SubscriptionEntity subscription, XMLGregorianCalendar startDate, XMLGregorianCalendar endDate) { @@ -57,11 +86,11 @@ public void notify(SubscriptionEntity subscription, String thirdPartyNotificationURI = subscription .getApplicationInformation().getThirdPartyNotifyUri(); String separator = "?"; - String subscriptionURI = subscription.getApplicationInformation() - .getDataCustodianResourceEndpoint() - + "/Batch/Subscription/" - + subscription.getId(); - + // Canonical ESPI Batch/Subscription form, built via the single source of truth (#160). + String subscriptionURI = EspiBatchUri.batchSubscription( + subscription.getApplicationInformation().getDataCustodianResourceEndpoint(), + String.valueOf(subscription.getId())); + if (startDate != null) { subscriptionURI = subscriptionURI + separator + "published-min=" + startDate.toXMLFormat(); @@ -120,7 +149,14 @@ protected void notifyInternal(String thirdPartyNotificationURI, if(thirdPartyNotificationURI != null){ try { - restTemplate.postForLocation(thirdPartyNotificationURI, batchList); + // Wire type is the JAXB DTO, serialized through the single canonical codec (#158). + String xml = BatchListXmlCodec.marshal(new BatchListDto(batchList.getResources())); + restClient.post() + .uri(thirdPartyNotificationURI) + .contentType(MediaType.APPLICATION_ATOM_XML) + .body(xml) + .retrieve() + .toBodilessEntity(); } catch (Exception e) { if(log.isErrorEnabled()) { log.info("NotificationServiceImpl: notifyInternal - POST for " + thirdPartyNotificationURI + diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/xml/BatchListXmlCodec.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/xml/BatchListXmlCodec.java new file mode 100644 index 00000000..f65c767a --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/xml/BatchListXmlCodec.java @@ -0,0 +1,112 @@ +/* + * + * 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.common.xml; + +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import jakarta.xml.bind.Marshaller; +import jakarta.xml.bind.Unmarshaller; +import org.greenbuttonalliance.espi.common.dto.usage.BatchListDto; + +import javax.xml.transform.stream.StreamSource; +import java.io.StringReader; +import java.io.StringWriter; + +/** + * Single canonical codec for the ESPI {@code BatchList} notification wire format (#158). + * + *

The Data Custodian→Third Party notification seam exchanges a {@code BatchList} XML document + * (a list of resource URIs to fetch). This codec is the single source of truth for that + * serialization: the DC sender ({@code NotificationServiceImpl}) marshals through it and the TP + * receiver ({@code NotificationController}) unmarshals through it, so the two sides cannot drift. + * It is the BatchList analogue of {@link org.greenbuttonalliance.espi.common.uri.EspiBatchUri} + * (the canonical Batch-URI codec) and is likewise a stateless static utility — no Spring wiring.

+ * + *

Per the project's JAXB rule the wire type is the JAXB-annotated {@link BatchListDto} + * ({@code @XmlRootElement(name = "BatchList", namespace = "http://naesb.org/espi")}), never the + * JPA entity. The {@link JAXBContext} is created once and cached (contexts are thread-safe; + * marshaller/unmarshaller instances are created per call as they are not).

+ */ +public final class BatchListXmlCodec { + + /** ESPI namespace of the {@code BatchList} root element. */ + public static final String ESPI_NAMESPACE = "http://naesb.org/espi"; + + /** Local name of the root element. */ + public static final String ROOT_ELEMENT = "BatchList"; + + private static final JAXBContext CONTEXT = createContext(); + + private BatchListXmlCodec() { + // static utility + } + + private static JAXBContext createContext() { + try { + return JAXBContext.newInstance(BatchListDto.class); + } catch (JAXBException e) { + throw new IllegalStateException("Failed to initialize BatchList JAXB context", e); + } + } + + /** + * Marshals a {@link BatchListDto} to its ESPI {@code BatchList} XML string. + * + * @param batchList the batch list to marshal (must not be {@code null}) + * @return the UTF-8 XML document as a string + * @throws IllegalArgumentException if {@code batchList} is {@code null} + * @throws IllegalStateException if marshalling fails + */ + public static String marshal(BatchListDto batchList) { + if (batchList == null) { + throw new IllegalArgumentException("batchList must not be null"); + } + try { + Marshaller marshaller = CONTEXT.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_ENCODING, "UTF-8"); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); + StringWriter writer = new StringWriter(); + marshaller.marshal(batchList, writer); + return writer.toString(); + } catch (JAXBException e) { + throw new IllegalStateException("Failed to marshal BatchList", e); + } + } + + /** + * Unmarshals an ESPI {@code BatchList} XML string to a {@link BatchListDto}. + * + * @param xml the XML document (must not be {@code null}) + * @return the parsed batch list + * @throws IllegalArgumentException if {@code xml} is {@code null} + * @throws IllegalStateException if unmarshalling fails + */ + public static BatchListDto unmarshal(String xml) { + if (xml == null) { + throw new IllegalArgumentException("xml must not be null"); + } + try { + Unmarshaller unmarshaller = CONTEXT.createUnmarshaller(); + return unmarshaller.unmarshal( + new StreamSource(new StringReader(xml)), BatchListDto.class).getValue(); + } catch (JAXBException e) { + throw new IllegalStateException("Failed to unmarshal BatchList", e); + } + } +} 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 new file mode 100644 index 00000000..8bfbd070 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/NotificationServiceImplWireContractTest.java @@ -0,0 +1,129 @@ +/* + * + * 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.common.service.impl; + +import com.sun.net.httpserver.HttpServer; +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity; +import org.greenbuttonalliance.espi.common.dto.usage.BatchListDto; +import org.greenbuttonalliance.espi.common.uri.EspiBatchUri; +import org.greenbuttonalliance.espi.common.xml.BatchListXmlCodec; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import javax.xml.datatype.DatatypeFactory; +import javax.xml.datatype.XMLGregorianCalendar; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Producer side of the DC→TP notification wire contract (#158). + * + *

Drives the real {@link NotificationServiceImpl} sender against a minimal standalone + * TP-receiver stub (a JDK {@link HttpServer} — no WireMock, no Spring, no Docker) and asserts + * the bytes actually put on the wire conform to the ESPI {@code BatchList} notification contract: + * the endpoint path, {@code application/atom+xml} content type, and a body that the canonical + * {@link BatchListXmlCodec} parses back to the expected {@code Batch/Subscription} resource URI.

+ */ +@DisplayName("DC→TP notification wire contract — DC producer + TP-receiver stub (#158)") +class NotificationServiceImplWireContractTest { + + private static final String DC_RESOURCE_BASE = + "https://dc.example.com/DataCustodian/espi/1_1/resource"; + + private HttpServer stub; + private final AtomicReference capturedMethod = new AtomicReference<>(); + private final AtomicReference capturedPath = new AtomicReference<>(); + private final AtomicReference capturedContentType = new AtomicReference<>(); + private final AtomicReference capturedBody = new AtomicReference<>(); + private CountDownLatch received; + + @BeforeEach + void startStub() throws Exception { + received = new CountDownLatch(1); + stub = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + stub.createContext("/ThirdParty/espi/1_1/Notification", exchange -> { + capturedMethod.set(exchange.getRequestMethod()); + capturedPath.set(exchange.getRequestURI().getPath()); + capturedContentType.set(exchange.getRequestHeaders().getFirst("Content-Type")); + capturedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + received.countDown(); + }); + stub.start(); + } + + @AfterEach + void stopStub() { + if (stub != null) { + stub.stop(0); + } + } + + @Test + @DisplayName("notify() POSTs a conformant ESPI BatchList to the TP notify URI") + void notifyPostsConformantBatchList() throws Exception { + UUID subscriptionId = UUID.fromString("11111111-2222-5333-8444-555555555555"); + String notifyUri = "http://" + stub.getAddress().getHostString() + + ":" + stub.getAddress().getPort() + "/ThirdParty/espi/1_1/Notification"; + + ApplicationInformationEntity appInfo = new ApplicationInformationEntity(); + appInfo.setThirdPartyNotifyUri(notifyUri); + appInfo.setDataCustodianResourceEndpoint(DC_RESOURCE_BASE); + + SubscriptionEntity subscription = new SubscriptionEntity(subscriptionId); + subscription.setApplicationInformation(appInfo); + + DatatypeFactory dtf = DatatypeFactory.newInstance(); + XMLGregorianCalendar start = dtf.newXMLGregorianCalendar("2025-01-01T00:00:00Z"); + XMLGregorianCalendar end = dtf.newXMLGregorianCalendar("2025-02-01T00:00:00Z"); + + // repositories/services are unused by the subscription notify path + NotificationServiceImpl service = + new NotificationServiceImpl(RestClient.create(), null, null, null); + + service.notify(subscription, start, end); + + assertThat(received.await(5, TimeUnit.SECONDS)) + .as("TP stub must have received the notification POST").isTrue(); + assertThat(capturedMethod.get()).isEqualTo("POST"); + assertThat(capturedPath.get()).isEqualTo("/ThirdParty/espi/1_1/Notification"); + assertThat(capturedContentType.get()).startsWith("application/atom+xml"); + + BatchListDto sent = BatchListXmlCodec.unmarshal(capturedBody.get()); + assertThat(sent.getResources()) + .singleElement(org.assertj.core.api.InstanceOfAssertFactories.STRING) + .contains("/Batch/Subscription/" + subscriptionId) + .contains("published-min=2025-01-01T00:00:00Z") + .contains("published-max=2025-02-01T00:00:00Z") + .satisfies(uri -> + assertThat(EspiBatchUri.subscriptionId(uri)).contains(subscriptionId.toString())); + } +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/xml/BatchListXmlCodecTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/xml/BatchListXmlCodecTest.java new file mode 100644 index 00000000..ddca4bd2 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/xml/BatchListXmlCodecTest.java @@ -0,0 +1,111 @@ +/* + * + * 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.common.xml; + +import org.greenbuttonalliance.espi.common.dto.usage.BatchListDto; +import org.greenbuttonalliance.espi.common.uri.EspiBatchUri; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link BatchListXmlCodec} — the single canonical ESPI {@code BatchList} + * wire codec (#158). Also binds the codec to the shared repo-root fixture + * {@code contracts/notification-batchlist.xml} (the producer side of the DC→TP contract). + */ +@DisplayName("BatchListXmlCodec (canonical ESPI BatchList XML codec) #158") +class BatchListXmlCodecTest { + + private static final String BASE = "https://utilityapi.com/DataCustodian/espi/1_1/resource"; + + @Test + @DisplayName("marshals to the ESPI BatchList root element and namespace (prefix-agnostic)") + void marshalProducesEspiBatchList() throws Exception { + String uri = EspiBatchUri.batchSubscription(BASE, "503888"); + String xml = BatchListXmlCodec.marshal(new BatchListDto(List.of(uri))); + + // The XML prefix (espi: vs default) is insignificant; the contract is the qualified name. + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + Document doc = factory.newDocumentBuilder() + .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + + assertThat(doc.getDocumentElement().getLocalName()).isEqualTo("BatchList"); + assertThat(doc.getDocumentElement().getNamespaceURI()).isEqualTo("http://naesb.org/espi"); + assertThat(xml).contains(uri); + } + + @Test + @DisplayName("marshal then unmarshal round-trips the resource list") + void roundTrips() { + List resources = List.of( + EspiBatchUri.batchSubscription(BASE, "503888"), + EspiBatchUri.batchBulk(BASE, "BULK_1")); + + BatchListDto parsed = BatchListXmlCodec.unmarshal( + BatchListXmlCodec.marshal(new BatchListDto(resources))); + + assertThat(parsed.getResources()).containsExactlyElementsOf(resources); + } + + @Test + @DisplayName("parses the shared contract fixture and its canonical Batch/Subscription URI") + void parsesContractFixture() throws Exception { + String xml = Files.readString(locateContractsDir().resolve("notification-batchlist.xml")); + + BatchListDto parsed = BatchListXmlCodec.unmarshal(xml); + + assertThat(parsed.getResources()) + .singleElement(org.assertj.core.api.InstanceOfAssertFactories.STRING) + .contains("/Batch/Subscription/503888") + .contains("published-min=") + .satisfies(uri -> + assertThat(EspiBatchUri.subscriptionId(uri)).contains("503888")); + } + + @Test + @DisplayName("rejects null inputs") + void rejectsNull() { + assertThatThrownBy(() -> BatchListXmlCodec.marshal(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> BatchListXmlCodec.unmarshal(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + /** Resolve repo-root {@code contracts/} whether tests run from the module dir or the repo root. */ + private static Path locateContractsDir() { + for (Path candidate : new Path[] { Path.of("..", "contracts"), Path.of("contracts") }) { + if (Files.isDirectory(candidate)) { + return candidate; + } + } + throw new IllegalStateException( + "contracts/ directory not found from " + Path.of("").toAbsolutePath()); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/WebConfiguration.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/WebConfiguration.java index 224a8438..5857db74 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/WebConfiguration.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/WebConfiguration.java @@ -25,7 +25,6 @@ import jakarta.xml.bind.Marshaller; import jakarta.xml.bind.Unmarshaller; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.restclient.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.MediaType; @@ -34,7 +33,6 @@ import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.http.converter.xml.MarshallingHttpMessageConverter; import org.springframework.oxm.jaxb.Jaxb2Marshaller; -import org.springframework.web.client.RestTemplate; import org.springframework.web.servlet.config.annotation.*; import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; @@ -219,20 +217,9 @@ public Marshaller espiMarshaller(Jaxb2Marshaller jaxb2Marshaller) throws JAXBExc /** * JAXB Unmarshaller for ESPI XML import. */ - @Bean + @Bean public Unmarshaller espiUnmarshaller(Jaxb2Marshaller jaxb2Marshaller) throws JAXBException { return jaxb2Marshaller.createUnmarshaller(); } - /** - * Creates a RestTemplate bean using the auto-configured RestTemplateBuilder. - * - * @param builder the RestTemplateBuilder provided by Spring Boot. - * @return a new instance of RestTemplate. - */ - @Bean - public RestTemplate restTemplate(RestTemplateBuilder builder) { - return builder.build(); - } - } \ No newline at end of file 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 e9ac6a95..4b1ac1e6 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 @@ -22,8 +22,10 @@ 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; @@ -31,7 +33,6 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.http.*; -import org.springframework.oxm.jaxb.Jaxb2Marshaller; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PostMapping; @@ -41,12 +42,7 @@ import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; -import jakarta.servlet.http.HttpServletResponse; -import javax.xml.transform.stream.StreamSource; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InputStream; -import java.util.concurrent.CompletableFuture; @Controller public class NotificationController extends BaseController { @@ -74,17 +70,15 @@ public class NotificationController extends BaseController { @Autowired private AuthorizationService authorizationService; - @Autowired(required = false) - public Jaxb2Marshaller marshaller; - @PostMapping("/espi/1_1/Notification") // TODO: Use Routes.THIRD_PARTY_NOTIFICATION when available public ResponseEntity notification(@RequestBody String xmlPayload) { try { - ByteArrayInputStream inputStream = new ByteArrayInputStream(xmlPayload.getBytes()); - BatchListEntity batchList = (BatchListEntity) marshaller.unmarshal(new StreamSource(inputStream)); + // 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); - batchListService.save(batchList); + batchListService.save(new BatchListEntity(batchList.getResources())); for (String resourceUri : batchList.getResources()) { doImportAsynchronously(resourceUri); @@ -232,8 +226,4 @@ public void setBatchListService(BatchListService batchListService) { public WebClient getWebClient() { return webClient; } - - public void setMarshaller(Jaxb2Marshaller marshaller) { - this.marshaller = marshaller; - } } diff --git a/openespi-thirdparty/src/test/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationControllerTests.java b/openespi-thirdparty/src/test/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationControllerTests.java deleted file mode 100644 index 2e4551d3..00000000 --- a/openespi-thirdparty/src/test/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationControllerTests.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * - * 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.thirdparty.web; - -//import org.greenbuttonalliance.espi.common.domain.BatchList; - -import org.aspectj.lang.annotation.Before; -import org.greenbuttonalliance.espi.common.service.BatchListService; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.oxm.jaxb.Jaxb2Marshaller; - -import java.io.IOException; - -import static org.mockito.Mockito.mock; - -//todo - JT, commenting out missing classes -public class NotificationControllerTests { - - @Mock - private BatchListService batchListService; - - public NotificationController controller; - - @BeforeEach - public void setup() { - MockitoAnnotations.initMocks(this); - controller = new NotificationController(); - controller.setBatchListService(batchListService); - controller.setMarshaller(mock(Jaxb2Marshaller.class)); - } - - @Test - @Disabled - public void notification() throws IOException { -// controller.notification(mock(HttpServletResponse.class), -// mock(InputStream.class)); -// verify(batchListService).persist(any(BatchList.class)); - } -} diff --git a/openespi-thirdparty/src/test/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationWireContractTest.java b/openespi-thirdparty/src/test/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationWireContractTest.java new file mode 100644 index 00000000..89644afb --- /dev/null +++ b/openespi-thirdparty/src/test/java/org/greenbuttonalliance/espi/thirdparty/web/NotificationWireContractTest.java @@ -0,0 +1,71 @@ +/* + * + * 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.thirdparty.web; + +import org.greenbuttonalliance.espi.common.dto.usage.BatchListDto; +import org.greenbuttonalliance.espi.common.uri.EspiBatchUri; +import org.greenbuttonalliance.espi.common.xml.BatchListXmlCodec; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Consumer side of the DC→TP notification wire contract (#158). + * + *

Binds the Third Party's notification-parsing logic — {@link BatchListXmlCodec} (XML unmarshal, + * as {@code NotificationController} does) and {@link EspiBatchUri} (id parsing of each resource URI) + * — to the shared, ESPI-standard fixture in the repo-root {@code contracts/} directory. If the DC→TP + * notification wire format drifts on either side, the producer test + * ({@code NotificationServiceImplWireContractTest}) or this consumer test fails.

+ * + *

Pure unit test (no Spring context, no Docker) — runs in CI as part of the TP module.

+ */ +@DisplayName("DC→TP notification wire contract — TP consumer (#158)") +class NotificationWireContractTest { + + @Test + @DisplayName("notification BatchList fixture conforms to the ESPI standard") + void notificationConformsToStandard() throws Exception { + String xml = Files.readString(locateContractsDir().resolve("notification-batchlist.xml")); + + BatchListDto batchList = BatchListXmlCodec.unmarshal(xml); + + assertThat(batchList.getResources()) + .singleElement(org.assertj.core.api.InstanceOfAssertFactories.STRING) + .contains("/Batch/Subscription/503888") + .contains("published-min=") + .satisfies(uri -> + assertThat(EspiBatchUri.subscriptionId(uri)).contains("503888")); + } + + /** Resolve repo-root {@code contracts/} whether tests run from the module dir or the repo root. */ + private static Path locateContractsDir() { + for (Path candidate : new Path[] { Path.of("..", "contracts"), Path.of("contracts") }) { + if (Files.isDirectory(candidate)) { + return candidate; + } + } + throw new IllegalStateException( + "contracts/ directory not found from " + Path.of("").toAbsolutePath()); + } +}