diff --git a/booking/src/main/java/org/dcsa/conformance/standards/booking/BookingScenarioListBuilder.java b/booking/src/main/java/org/dcsa/conformance/standards/booking/BookingScenarioListBuilder.java index 7742a858..52b0185f 100644 --- a/booking/src/main/java/org/dcsa/conformance/standards/booking/BookingScenarioListBuilder.java +++ b/booking/src/main/java/org/dcsa/conformance/standards/booking/BookingScenarioListBuilder.java @@ -24,6 +24,8 @@ public class BookingScenarioListBuilder extends ScenarioListBuilder + new UC12_Shipper_CancelEntireBookingAction( + carrierPartyName, + shipperPartyName, + (BookingAction) previousAction, + componentFactory.getMessageSchemaValidator( + new JsonSchema(BOOKING_API, CANCEL_SCHEMA_NAME)))); } private static BookingScenarioListBuilder tbdCarrierAction() { diff --git a/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC12_Shipper_CancelEntireBookingAction.java b/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC12_Shipper_CancelEntireBookingAction.java new file mode 100644 index 00000000..fb7afec4 --- /dev/null +++ b/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC12_Shipper_CancelEntireBookingAction.java @@ -0,0 +1,74 @@ +package org.dcsa.conformance.standards.booking.action; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.dcsa.conformance.core.check.*; +import org.dcsa.conformance.core.traffic.HttpMessageType; +import org.dcsa.conformance.standards.booking.party.BookingRole; + +import java.util.stream.Stream; + +@Getter +@Slf4j +public class UC12_Shipper_CancelEntireBookingAction extends BookingAction { + private final JsonSchemaValidator requestSchemaValidator; + + public UC12_Shipper_CancelEntireBookingAction( + String carrierPartyName, + String shipperPartyName, + BookingAction previousAction, + JsonSchemaValidator requestSchemaValidator) { + super(shipperPartyName, carrierPartyName, previousAction, "UC12", 200); + this.requestSchemaValidator = requestSchemaValidator; + } + + @Override + public String getHumanReadablePrompt() { + return ("UC12: Cancel an entire booking"); + } + + @Override + public JsonNode getJsonForHumanReadablePrompt() { + return getCspSupplier().get().toJson(); + } + + @Override + public ObjectNode asJsonNode() { + ObjectNode jsonNode = super.asJsonNode(); + jsonNode.put("cbrr", getDspSupplier().get().carrierBookingRequestReference()); + return jsonNode; + } + + @Override + public ConformanceCheck createCheck(String expectedApiVersion) { + return new ConformanceCheck(getActionTitle()) { + @Override + protected Stream createSubChecks() { + var cbrr = getDspSupplier().get().carrierBookingRequestReference(); + return Stream.of( + new UrlPathCheck(BookingRole::isShipper, getMatchedExchangeUuid(), "/v2/bookings/%s".formatted(cbrr)), + new ResponseStatusCheck( + BookingRole::isCarrier, getMatchedExchangeUuid(), expectedStatus), + new ApiHeaderCheck( + BookingRole::isShipper, + getMatchedExchangeUuid(), + HttpMessageType.REQUEST, + expectedApiVersion), + new ApiHeaderCheck( + BookingRole::isCarrier, + getMatchedExchangeUuid(), + HttpMessageType.RESPONSE, + expectedApiVersion), + new JsonSchemaCheck( + BookingRole::isShipper, + getMatchedExchangeUuid(), + HttpMessageType.REQUEST, + requestSchemaValidator)) + // .filter(Objects::nonNull) + ; + } + }; + } +} diff --git a/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC1_Shipper_SubmitBookingRequestAction.java b/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC1_Shipper_SubmitBookingRequestAction.java index e95e888f..6acbb552 100644 --- a/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC1_Shipper_SubmitBookingRequestAction.java +++ b/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC1_Shipper_SubmitBookingRequestAction.java @@ -45,6 +45,7 @@ public ObjectNode asJsonNode() { @Override public void doHandleExchange(ConformanceExchange exchange) { JsonNode responseJsonNode = exchange.getResponse().message().body().getJsonBody(); + // FIXME: Guard against non-conformant parties getDspConsumer() .accept( new DynamicScenarioParameters( diff --git a/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC5_Carrier_ConfirmBookingRequestAction.java b/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC5_Carrier_ConfirmBookingRequestAction.java index 227fd965..73af62d9 100644 --- a/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC5_Carrier_ConfirmBookingRequestAction.java +++ b/booking/src/main/java/org/dcsa/conformance/standards/booking/action/UC5_Carrier_ConfirmBookingRequestAction.java @@ -43,6 +43,7 @@ public ObjectNode asJsonNode() { @Override public void doHandleExchange(ConformanceExchange exchange) { JsonNode responseJsonNode = exchange.getResponse().message().body().getJsonBody(); + // FIXME: Guard against non-conformant parties var cbr = responseJsonNode.get("carrierBookingReference").asText(); var dsp = getDspSupplier().get(); if (cbr != null && !cbr.isBlank()) { diff --git a/booking/src/main/java/org/dcsa/conformance/standards/booking/party/Carrier.java b/booking/src/main/java/org/dcsa/conformance/standards/booking/party/Carrier.java index 198043f9..b7fa4f0d 100644 --- a/booking/src/main/java/org/dcsa/conformance/standards/booking/party/Carrier.java +++ b/booking/src/main/java/org/dcsa/conformance/standards/booking/party/Carrier.java @@ -18,12 +18,15 @@ import org.dcsa.conformance.core.traffic.ConformanceResponse; import org.dcsa.conformance.standards.booking.action.*; +import java.net.MalformedURLException; +import java.net.URL; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.*; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.StreamSupport; @Slf4j @@ -372,6 +375,17 @@ private void requestUpdateToConfirmedBooking(JsonNode actionPrompt) { addOperatorLogEntry("Requested update to the booking with CBR '%s'".formatted(cbr)); } + private void checkState(String reference, BookingState currentState, Predicate expectedState) { + if (!expectedState.test(currentState)) { + throw new IllegalStateException( + "Booking '%s' is in state '%s'".formatted(reference, currentState)); + } + } + + private void checkState(String reference, BookingState currentState, Set expectedState) { + checkState(reference, currentState, expectedState::contains); + } + private void processAndEmitNotificationForStateTransition( JsonNode actionPrompt, BookingState targetState, @@ -399,10 +413,7 @@ private void processAndEmitNotificationForStateTransition( String cbr = cbrrToCbr.get(cbrr); BookingState currentState = bookingStatesByCbrr.get(cbrr); boolean isCorrect = actionPrompt.path("isCorrect").asBoolean(true); - if (!expectedState.contains(currentState)) { - throw new IllegalStateException( - "Booking '%s' is in state '%s'".formatted(cbrr, currentState)); - } + checkState(cbrr, currentState, expectedState); if (isCorrect) { var booking = (ObjectNode)persistentMap.load(cbrr); @@ -481,16 +492,39 @@ private ConformanceResponse return405(ConformanceRequest request, String ... all } - private ConformanceResponse return404(ConformanceRequest request) { + private ConformanceResponse return400(ConformanceRequest request, String message) { return request.createResponse( - 404, + 400, Map.of("Api-Version", List.of(apiVersion)), new ConformanceMessageBody( objectMapper .createObjectNode() .put( - "message", - "Returning 404 since the request did not match any known URL"))); + "message", message))); + } + + private ConformanceResponse return404(ConformanceRequest request) { + return request.createResponse( + 404, + Map.of("Api-Version", List.of(apiVersion)), + new ConformanceMessageBody( + objectMapper + .createObjectNode() + .put( + "message", + "Returning 404 since the request did not match any known URL"))); + } + + private ConformanceResponse return409(ConformanceRequest request, String message) { + return request.createResponse( + 409, + Map.of("Api-Version", List.of(apiVersion)), + new ConformanceMessageBody( + objectMapper + .createObjectNode() + .put( + "message", + message))); } @Override @@ -505,7 +539,8 @@ public ConformanceResponse handleRequest(ConformanceRequest request) { } yield return404(request); } - case "PUT", "PATCH" -> throw new UnsupportedOperationException(); + case "PATCH" -> _handlePatchBookingRequest(request); + case "PUT" -> throw new UnsupportedOperationException(); default -> return405(request, "GET", "POST", "PUT", "PATCH"); }; addOperatorLogEntry( @@ -519,6 +554,85 @@ private String lastUrlSegment(String url) { return url.substring(1 + url.replaceAll("/++$", "").lastIndexOf("/")); } + private String readCancelOperation(ConformanceRequest request) { + var queryParams = request.queryParams(); + var operationParams = queryParams.get("operation"); + if (operationParams == null || operationParams.isEmpty()) { + return null; + } + var operation = operationParams.iterator().next(); + if (operationParams.size() > 1 || !(operation.equals("cancelBooking") || operation.equals("cancelAmendment"))) { + return "!INVALID-VALUE!"; + } + return operation; + } + + private ConformanceResponse _handlePatchBookingRequest(ConformanceRequest request) { + var cancelOperation = readCancelOperation(request); + if (cancelOperation == null) { + return return400(request, "Missing mandatory 'operation' query parameter"); + } + if (!cancelOperation.equals("cancelBooking") && !cancelOperation.equals("cancelAmendment")) { + return return400(request, + "The 'operation' query parameter must be given exactly one and have" + + " value either 'cancelBooking' OR 'cancelAmendment'" + ); + } + var bookingReference = lastUrlSegment(request.url()); + // bookingReference can either be a CBR or CBRR. + var cbrr = cbrToCbrr.getOrDefault(bookingReference, bookingReference); + if (!bookingStatesByCbrr.containsKey(cbrr)) { + return return404(request); + } + ObjectNode booking; + try { + booking = setState(cbrr, BookingState.CANCELLED, s -> s != BookingState.CANCELLED); + } catch (IllegalStateException e) { + return return409(request, "Booking was not in the correct state"); + } + return returnBookingStatusResponse(200, request, booking, bookingReference); + } + + private ConformanceResponse returnBookingStatusResponse(int responseCode, ConformanceRequest request, ObjectNode booking, String bookingReference) { + var cbrr = booking.get("carrierBookingRequestReference").asText(); + var bookingStatus = booking.get("bookingStatus").asText(); + var statusObject = objectMapper.createObjectNode() + .put("bookingStatus", bookingStatus) + .put("carrierBookingRequestReference", cbrr); + var cbr = booking.get("carrierBookingReference"); + var reason = booking.get("reason"); + if (cbr != null) { + statusObject.set("carrierBookingReference", cbr); + } + if (cbr != null) { + statusObject.set("reason", reason); + } + ConformanceResponse response = + request.createResponse( + responseCode, + Map.of("Api-Version", List.of(apiVersion)), + new ConformanceMessageBody(statusObject)); + addOperatorLogEntry( + "Responded %d to %s booking '%s' (resulting state '%s')" + .formatted(responseCode, request.method(), bookingReference, bookingStatus)); + return response; + } + + private ObjectNode setState(String carrierBookingRequestReference, BookingState newState, Predicate expectedState) { + var booking = persistentMap.load(carrierBookingRequestReference); + if (booking == null) { + throw new IllegalArgumentException("Unknown CBRR: " + carrierBookingRequestReference); + } + checkState( + carrierBookingRequestReference, + BookingState.fromWireName(booking.required("bookingStatus").asText()), + expectedState + ); + ((ObjectNode)booking).put("bookingStatus", newState.wireName()); + bookingStatesByCbrr.put(carrierBookingRequestReference, newState); + return (ObjectNode) booking; + } + private ConformanceResponse _handleGetBookingRequest(ConformanceRequest request) { var bookingReference = lastUrlSegment(request.url()); // bookingReference can either be a CBR or CBRR. @@ -543,26 +657,13 @@ private ConformanceResponse _handlePostBookingRequest(ConformanceRequest request String cbrr = UUID.randomUUID().toString(); BookingState bookingState = BookingState.RECEIVED; bookingStatesByCbrr.put(cbrr, bookingState); - ConformanceResponse response = - request.createResponse( - 201, - Map.of("Api-Version", List.of(apiVersion)), - new ConformanceMessageBody( - objectMapper - .createObjectNode() - .put("carrierBookingRequestReference", cbrr) - .put("bookingStatus", bookingState.wireName()))); - ObjectNode booking = (ObjectNode) objectMapper.readTree(request.message().body().getJsonBody().toString()); booking.put("carrierBookingRequestReference", cbrr); booking.put("bookingStatus", bookingState.wireName()); persistentMap.save(cbrr, booking); - addOperatorLogEntry( - "Accepted booking request '%s' (now in state '%s')" - .formatted(cbrr, bookingState.wireName())); - return response; + return returnBookingStatusResponse(201, request, booking, cbrr); } @Builder diff --git a/booking/src/main/java/org/dcsa/conformance/standards/booking/party/Shipper.java b/booking/src/main/java/org/dcsa/conformance/standards/booking/party/Shipper.java index 2ba04565..0bb14854 100644 --- a/booking/src/main/java/org/dcsa/conformance/standards/booking/party/Shipper.java +++ b/booking/src/main/java/org/dcsa/conformance/standards/booking/party/Shipper.java @@ -16,6 +16,7 @@ import org.dcsa.conformance.core.traffic.ConformanceRequest; import org.dcsa.conformance.core.traffic.ConformanceResponse; import org.dcsa.conformance.standards.booking.action.Shipper_GetBookingAction; +import org.dcsa.conformance.standards.booking.action.UC12_Shipper_CancelEntireBookingAction; import org.dcsa.conformance.standards.booking.action.UC1_Shipper_SubmitBookingRequestAction; @Slf4j @@ -55,7 +56,8 @@ protected void doReset() { protected Map, Consumer> getActionPromptHandlers() { return Map.ofEntries( Map.entry(UC1_Shipper_SubmitBookingRequestAction.class, this::sendBookingRequest), - Map.entry(Shipper_GetBookingAction.class, this::getBookingRequest)); + Map.entry(Shipper_GetBookingAction.class, this::getBookingRequest), + Map.entry(UC12_Shipper_CancelEntireBookingAction.class, this::sendCancelEntireBooking)); } private void getBookingRequest(JsonNode actionPrompt) { @@ -90,6 +92,21 @@ private void sendBookingRequest(JsonNode actionPrompt) { .formatted(carrierScenarioParameters.toJson())); } + private void sendCancelEntireBooking(JsonNode actionPrompt) { + log.info("Shipper.sendCancelEntireBooking(%s)".formatted(actionPrompt.toPrettyString())); + String cbrr = actionPrompt.get("cbrr").asText(); + + asyncCounterpartPatch( + "/v2/bookings/%s?operation=cancelBooking".formatted(cbrr), + new ObjectMapper().createObjectNode() + .put("bookingStatus", BookingState.CANCELLED.wireName()) + ); + + addOperatorLogEntry( + "Sent a cancel booking request of '%s'" + .formatted(cbrr)); + } + @Override public ConformanceResponse handleRequest(ConformanceRequest request) { log.info("Shipper.handleRequest(%s)".formatted(request)); diff --git a/core/src/main/java/org/dcsa/conformance/core/party/ConformanceParty.java b/core/src/main/java/org/dcsa/conformance/core/party/ConformanceParty.java index b13343e2..94e0fe6e 100644 --- a/core/src/main/java/org/dcsa/conformance/core/party/ConformanceParty.java +++ b/core/src/main/java/org/dcsa/conformance/core/party/ConformanceParty.java @@ -166,23 +166,28 @@ protected void asyncCounterpartGet(String path) { } protected void asyncCounterpartPatch(String path, JsonNode jsonBody) { - _asyncCounterpartPatchPostOrPut("PATCH", path, jsonBody); + _asyncCounterpartPatchPostOrPut("PATCH", path, Collections.emptyMap(), jsonBody); } protected void asyncCounterpartPost(String path, JsonNode jsonBody) { - _asyncCounterpartPatchPostOrPut("POST", path, jsonBody); + _asyncCounterpartPatchPostOrPut("POST", path, Collections.emptyMap(), jsonBody); } protected void asyncCounterpartPut(String path, JsonNode jsonBody) { - _asyncCounterpartPatchPostOrPut("PUT", path, jsonBody); + _asyncCounterpartPatchPostOrPut("PUT", path, Collections.emptyMap(), jsonBody); } - private void _asyncCounterpartPatchPostOrPut(String method, String path, JsonNode jsonBody) { + private void _asyncCounterpartPatchPostOrPut( + String method, + String path, + Map> queryParams, + JsonNode jsonBody + ) { asyncWebClient.accept( new ConformanceRequest( method, counterpartConfiguration.getUrl() + path, - Collections.emptyMap(), + queryParams, new ConformanceMessage( partyConfiguration.getName(), partyConfiguration.getRole(), diff --git a/core/src/main/java/org/dcsa/conformance/core/traffic/ConformanceRequest.java b/core/src/main/java/org/dcsa/conformance/core/traffic/ConformanceRequest.java index bbac3ec8..f906a0fd 100644 --- a/core/src/main/java/org/dcsa/conformance/core/traffic/ConformanceRequest.java +++ b/core/src/main/java/org/dcsa/conformance/core/traffic/ConformanceRequest.java @@ -3,6 +3,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.net.*; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Map; import org.dcsa.conformance.core.toolkit.JsonToolkit; @@ -39,6 +42,29 @@ public ObjectNode toJson() { return objectNode; } + public URI toURI() throws MalformedURLException, URISyntaxException { + if (this.queryParams.isEmpty()) { + return new URL(url).toURI(); + } + var b = new StringBuilder(url); + b.append("?"); + boolean first = true; + for (var queryParam : queryParams.entrySet()) { + var encodedQueryParamName = URLEncoder.encode(queryParam.getKey(), StandardCharsets.UTF_8); + for (var queryParamValue : queryParam.getValue()) { + if (!first) { + b.append("&"); + } + first = false; + b.append(encodedQueryParamName) + .append("=") + .append(URLEncoder.encode(queryParamValue, StandardCharsets.UTF_8)); + + } + } + return new URL(b.toString()).toURI(); + } + public static ConformanceRequest fromJson(ObjectNode objectNode) { return new ConformanceRequest( objectNode.get("method").asText(), diff --git a/sandbox/src/main/java/org/dcsa/conformance/sandbox/ConformanceSandbox.java b/sandbox/src/main/java/org/dcsa/conformance/sandbox/ConformanceSandbox.java index d9981d47..53001968 100644 --- a/sandbox/src/main/java/org/dcsa/conformance/sandbox/ConformanceSandbox.java +++ b/sandbox/src/main/java/org/dcsa/conformance/sandbox/ConformanceSandbox.java @@ -649,7 +649,7 @@ private static AbstractComponentFactory _createComponentFactory( @SneakyThrows private static ConformanceResponse _syncHttpRequest(ConformanceRequest conformanceRequest) { - URI uri = URI.create(conformanceRequest.url()); + URI uri = conformanceRequest.toURI(); log.info( "ConformanceSandbox.syncHttpRequest(%s) calling: %s" .formatted(conformanceRequest.toJson().toPrettyString(), uri));