How do i retry a message based on failed HttpStatus (401,400) of HttpOutBoundGateway - spring-boot

Basically my use-case is to retry an http -request when a 401 occurs in an HttpOutboundGateway request . The request comes from a jms broker into the integration flow .
#Bean
IntegrationFlow bank2wallet(ConnectionFactory jmsConnectionFactory,
MessageHandler creditWalletHttpGateway) {
return IntegrationFlows.from(Jms.messageDrivenChannelAdapter(jmsConnectionFactory)
.destination(cp.getTransactionIn()))
.<String, CreditRequest>transform(
request -> new Gson().fromJson(request, CreditRequest.class))
.enrichHeaders((headerEnricherSpec -> {
// Todo get token from cache
headerEnricherSpec.header(HttpHeaders.AUTHORIZATION, String.join(" ", "Bearer", ""));
headerEnricherSpec.header(HttpHeaders.ACCEPT, "application/json");
headerEnricherSpec.header(HttpHeaders.CONTENT_TYPE, "application/json");
}))
.handle(creditWalletHttpGateway, (e) -> e.advice(retryAdvice()))
.get();
}
#Bean
MessageHandler creditWalletHttpGateway( #Value("${api.base.uri:https:/localhost/v3/sync}") URI uri) {
HttpRequestExecutingMessageHandler httpHandler = new HttpRequestExecutingMessageHandler(uri);
httpHandler.setExpectedResponseType(CreditResponse.class);
httpHandler.setHttpMethod(HttpMethod.POST);
return httpHandler;
}
#Bean
RequestHandlerRetryAdvice retryAdvice() {
RequestHandlerRetryAdvice requestHandlerRetryAdvice = new RequestHandlerRetryAdvice();
requestHandlerRetryAdvice.setRecoveryCallback(errorMessageSendingRecoverer());
return requestHandlerRetryAdvice;
}
#Bean
ErrorMessageSendingRecoverer errorMessageSendingRecoverer() {
return new ErrorMessageSendingRecoverer(recoveryChannel());
}
#Bean
MessageChannel recoveryChannel() {
return new DirectChannel();
}
#Bean
MessageChannel retryChannel() {
return new DirectChannel();
}
#Bean
IntegrationFlow handleRecovery() {
return IntegrationFlows.from("recoveryChannel")
.log(Level.ERROR, "error", m -> m.getPayload())
.<RuntimeException>handle((message) -> {
MessagingException exception = (MessagingException) message.getPayload();
Message<CreditRequest> originalCreditRequest = (Message<CreditRequest>) exception.getFailedMessage();
// String token = gateway.getToken(configProperties);
String token = UUID.randomUUID().toString();
Message<CreditRequest> c = MessageBuilder.fromMessage(originalCreditRequest)
.setHeader(ApiConstants.AUTHORIZATION, String.join(" ", "Bearer", token))
.copyHeaders(message.getHeaders())
.build();
retryChannel().send(c);
})
.get();
}
#Bean
IntegrationFlow creditRequestFlow() {
return IntegrationFlows.from(retryChannel())
.log(Level.INFO, "info", m -> m.getPayload())
.handle(Http.outboundGateway("https://localhost/v3/sync")
.httpMethod(HttpMethod.POST)
.expectedResponseType(CreditResponse.class))
.get();
}
Headers are enriched with the appropriate http header,
Then i have an advice that retries the request with default simple policy , the issue with RequestHandlerAdvice approach is that it defaults the Exception Message in the handleRecovery Flow to a none HttpException class (MessageException), hence i cant check for HttpStatus code to re-route the message. So my question is basically how do i design a flow that retries a HttpOutBoundRequest based on HttpStatus 401.

I resolved the issue by introducing a gateway to make the outbound http call and manage it using a recursive manner
#MessagingGateway
public interface B2WGateway {
/**
*
* #param message
* #return
*/
#Gateway(requestChannel = "credit.input")
CreditResponse bankToWallet(Message<CreditRequest> message);
}
Then isolated the http outbound integration flow
#Bean
IntegrationFlow credit() {
return f -> f.log(Level.INFO, "info", m -> m.getHeaders())
.handle(Http.outboundGateway(configProperties.getBankToWalletUrl())
.httpMethod(HttpMethod.POST)
.expectedResponseType(CreditResponse.class)
.errorHandler(new ResponseErrorHandler() {
#Override
public boolean hasError(ClientHttpResponse clientHttpResponse) throws IOException {
return clientHttpResponse.getStatusCode().equals(HttpStatus.UNAUTHORIZED);
}
#Override
public void handleError(ClientHttpResponse clientHttpResponse) throws IOException {
throw new AuthenticationRequiredException("Authentication Required");
}
}));
}
Then resolve the message the handleRecovery to send the message after obtaining a token refresh
#Bean
IntegrationFlow handleRecovery() {
return IntegrationFlows.from("recoveryChannel")
.log(Level.ERROR, "error", m -> m.getPayload())
.<RuntimeException>handle((p, h) -> {
MessageHandlingExpressionEvaluatingAdviceException exception = (MessageHandlingExpressionEvaluatingAdviceException) p;
Message<CreditRequest> originalCreditRequest = (Message<CreditRequest>) exception
.getFailedMessage();
// String token = gateway.getToken(configProperties);
String token = UUID.randomUUID().toString();
Message<CreditRequest> c = MessageBuilder.fromMessage(originalCreditRequest)
.setHeader(ApiConstants.AUTHORIZATION, String.join(" ", "Bearer", token))
.copyHeaders(h)
.build();
return c;
})
.channel("credit.input")
.get();
}
Then modified the inception of the flow to use the gateway service and expression advice.
#Bean
IntegrationFlow bank2wallet(ConnectionFactory jmsConnectionFactory) {
return IntegrationFlows.from(Jms.messageDrivenChannelAdapter(jmsConnectionFactory)
.destination(cp.getTransactionIn()))
.<String, CreditRequest>transform(
request -> new Gson().fromJson(request, CreditRequest.class))
.enrichHeaders((headerEnricherSpec -> {
// Todo get token from cache
headerEnricherSpec.header(HttpHeaders.AUTHORIZATION, String.join(" ", "Bearer", ""));
headerEnricherSpec.header(HttpHeaders.ACCEPT, "application/json");
headerEnricherSpec.header(HttpHeaders.CONTENT_TYPE, "application/json");
}))
.handle((GenericHandler<CreditRequest>) (creditRequest, headers) -> gateway
.bankToWallet(MessageBuilder.withPayload(creditRequest)
.copyHeaders(headers)
.build()), (e) -> e.advice(retryAdvice()))
.get();
}
Inspiration from Spring Integration - Manage 401 Error in http outbound adapter call

Related

Spring Integration : Transform return null values in Web Service SOAP Request

Spring Integration Transformer returns null values in Webservic SOAP request. It is a Database to Webservice SOAP integration point. Can someone help me to find the solution?
Integration Configuration:
#SuppressWarnings("unchecked")
#Bean
public Transformer transformer() {
return new Transformer() {
#Override
public Message<?> transform(Message<?> message) {
// Get the payload of the incoming message
List<Model> patients = (List<Model>) message.getPayload();
// Convert the list of model to a SOAP message
Method request = new Method();
request.setSourceID(model.get(0).getSourceID());
request.setUser(model.get(0).getUser());
try {
// Create a new SOAP message using a JAXB marshaller
SOAPMessage soapMessage = MessageFactory.newInstance().createMessage();
SOAPPart soapPart = soapMessage.getSOAPPart();
// Create a new SOAP envelope and add the body element
SOAPEnvelope soapEnvelope = soapPart.getEnvelope();
soapEnvelope.removeNamespaceDeclaration("SOAP-ENV");
soapEnvelope.setPrefix("soap");
soapEnvelope.addNamespaceDeclaration("soap",
"http://schemas.xmlsoap.org/soap/envelope/");
soapEnvelope.getHeader().detachNode(); // Remove the SOAP header
SOAPBody soapBody = soapEnvelope.getBody();
soapBody.setPrefix("soap");
....
// Add the request fields as child elements of the body element
SOAPElement systemCodeElement = mergeRecordElement.addChildElement("systemCode");
....
SOAPElement userElement = mergeRecordElement.addChildElement("user");
userElement.addTextNode(request.getUser());
// Convert the SOAP message to a String
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
soapMessage.writeTo(outputStream);
String soapMessageString = new String(outputStream.toByteArray());
QName rootElement = new QName("http://sample.project.com/",
"method");
JAXBElement<String> jaxbElement = new JAXBElement<>(rootElement,
String.class, soapMessageString);
System.out.println(soapMessageString);
return new GenericMessage<>(jaxbElement);
} catch (JAXBException | SOAPException | IOException e) {
e.printStackTrace();
throw new MessagingException("Failed to write SOAP message", e);
}
}
};
}
#Bean
public IntegrationFlow integrationFlow() throws NamingException {
return IntegrationFlows
.from(jdbcPollingChannelAdapter(),
c -> c.poller(Pollers.fixedDelay(10000)))
.transform(transformer())
.handle(marshallingWebServiceOutboundGateway())
.get();
}
#Bean
public MarshallingWebServiceOutboundGateway marshallingWebServiceOutboundGateway() {
MarshallingWebServiceOutboundGateway gateway;
gateway = new MarshallingWebServiceOutboundGateway(
"URL", marshaller());
gateway.setRequiresReply(false);
gateway.setOutputChannelName("responseChannel");
return gateway;
}
#Bean
public Jaxb2Marshaller marshaller() {
Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
marshaller.setContextPath("com.project.sample");
return marshaller;
}
Transformer returns JAXBElement. Testing created soapRequest on SOAPUI, and working fine as expected. Below is the error trace
ERROR 10580 --- [ scheduling-1] o.s.integration.handler.LoggingHandler : org.springframework.messaging.MessageHandlingException: error occurred in message handler [bean
'marshallingWebServiceOutboundGateway' for component 'integrationFlow.org.springframework.integration.config.ConsumerEndpointFactoryBean#1'; defined in: 'class path resource [com/oracle/patientmerge/config/IntegrationConfig.class]'; from source: 'com.oracle.patientmerge.config.IntegrationConfig.marshallingWebServiceOutboundGateway()']; nested exception is org.springframework.ws.soap.client.SoapFaultClientException
Caused by: org.springframework.ws.soap.client.SoapFaultClientException:

How to use #ControllerAdvice to handle webclient errors from the reactive stack (web flux -> spring)

I use webclient from weblux to send a request to a remote server. At this point, I can get error 400. I need to intercept it and send it to the client.
webClient
.post()
.uri(
)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(
BodyInserters
.fromFormData()
.with()
.with()
)
.retrieve()
.onStatus(
HttpStatus::isError, response -> response.bodyToMono(String.class) // error body as String or other class
.flatMap(error -> Mono.error(new WrongCredentialsException(error)))
)
.bodyToMono(TResponse.class)
.doOnNext(...);
error
#ControllerAdvice
#Slf4j
public class ApplicationErrorHandler {
#ExceptionHandler(WrongCredentialsException.class)
public ResponseEntity<ErrorResponse> handleResponseException(WrongCredentialsException ex) {
// log.error("Error from WebClient - Status {}, Body {}", ex.getRawStatusCode(), ex.getResponseBodyAsString(), ex);
ErrorResponse error = new ErrorResponse();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(error);
}
#Data
#NoArgsConstructor
#AllArgsConstructor
public class ErrorResponse {
private String errorCode;
private String message;
}
rest api
#PostMapping
public ResponseEntity<String> send(#RequestBody Dto dto) {
log.debug("An notification has been send to user");
return new ResponseEntity<>(HttpStatus.OK);
}
I tried the options from here, but it didn't work out . Can someone explain how it works and how it can be configured for my case?
first case
return Objects.requireNonNull(oauthWebClient
.post()
.uri(uri)
.bodyValue(dto)
.attributes(oauth2AuthorizedClient(authorizedClient))
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.exchangeToMono(response -> {
HttpStatus httpStatus = response.statusCode();
if (httpStatus.is4xxClientError()) {
getErrFromClient(response, httpStatus);
}
if (httpStatus.is5xxServerError()) {
getErrFromServer(response, httpStatus);
}
return Mono.just(ResponseEntity.status(response.statusCode()));
})
.block())
.build();
}
private void getErrFromServer(DtoResponse response, HttpStatus httpStatus) {
String err = response.bodyToMono(String.class).toString();
log.error("HttpStatus: {}, message: {}", httpStatus, err);
HttpHeaders httpHeaders = response.headers().asHttpHeaders();
List<String> errorBody = httpHeaders.get("errBody");
assert errBody != null;
throw new CustomException(
"{ HttpStatus : " + httpStatus + " , message : " + errBody + " }");
}
private void getErrFromClient(DtoResponse response, HttpStatus httpStatus) {
String err = response.bodyToMono(String.class).toString();
log.error("HttpStatus: {}, err: {}", httpStatus, err);
HttpHeaders httpHeaders = response.headers().asHttpHeaders();
List<String> errorBody = httpHeaders.get("errBody");
assert errBody != null;
throw new CustomException(
"{ HttpStatus : " + httpStatus + " , message : " + errBody + " }");
}
and than
#ControllerAdvice
public class HandlerAdviceException {
#ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
//here your code
//for example:
String errMessage = e.getLocalizedMessage();
return ResponseEntity
.internalServerError()
.body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, errMessage));
}
}
second case
return webClient
.post()
.uri(
properties......,
Map.of("your-key", properties.get...())
)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(
prepare....()
)
.retrieve()
.bodyToMono(TokenResponse.class)
.doOnSuccess(currentToken::set);
}
Here, if successful, you will get the result you need, but if an error occurs, then you only need to configure the interceptor in the Advice Controller for WebClientResponseException.
#ControllerAdvice
#Slf4j
public class CommonRestExceptionHandler extends ResponseEntityExceptionHandler {
#ExceptionHandler(WebClientResponseException.class)
protected ResponseEntity<ApiErrorResponse> handleWebClientResponseException(WebClientResponseException ex) {
log.error(ex.getClass().getCanonicalName());
String errMessageAdditional = .....
final ApiErrorResponse apiError = ApiErrorResponse.builder()
.message(ex.getLocalizedMessage())
.status(HttpStatus.UNAUTHORIZED)
.build();
//if it needs
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(.......);
return new ResponseEntity<>(apiError, httpHeaders, apiError.getStatus());
}
}

WebClient ExchangeFilterFunction JUnit

How do I get code coverage for ClientResponseErrorService.genericClientFilter?
#PostConstruct
public void init() throws SSLException {
SslContext sslContext = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE)
.build();
HttpClient httpClient = HttpClient.create().secure(sslContextSpec -> sslContextSpec.sslContext(sslContext));
ClientHttpConnector restConnector = new ReactorClientHttpConnector(httpClient);
webClient = WebClient.builder()
.baseUrl(basePath)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.filter(ClientResponseErrorService.genericClientFilter)
.clientConnector(restConnector).build();
}
Here is the ClientResponseErrorService class which I am using inside filter function.
public static ExchangeFilterFunction genericClientFilterFunction() {
return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
if (clientResponse.statusCode().isError()) {
clientResponse.bodyToMono(String.class).flatMap(clientResponseBody -> {
try {
log.info("Into error Handler");
return Mono.error(getException(clientResponse, clientResponseBody));
} catch (Exception ex) {
return Mono.error(getException(clientResponse, clientResponseBody));
}
});
return Mono.just(clientResponse);
} else {
return Mono.just(clientResponse);
}
});
}
I tried this way by creating a MockWebServer
#Test
public void exchangeFilterFunctionTest() throws Exception {
server.enqueue(new MockResponse().addHeader("Content-Type", "application/json; charset=utf-8")
.setBody("{}").throttleBody(10000, 3, TimeUnit.MILLISECONDS).setResponseCode(400));
server.enqueue(new MockResponse().addHeader("Content-Type", "application/json; charset=utf-8")
.setBody("{}").throttleBody(10000, 3, TimeUnit.MILLISECONDS).setResponseCode(401));
WebClient webClient = WebClient.builder()
.baseUrl("http://localhost:"+server.getPort())
.filter(ClientResponseErrorService.genericClientFilterFunction())
.build();
Mono<String> responseMono = webClient.get()
.uri("http://localhost:"+server.getPort()+"/api")
.retrieve()
.bodyToMono(String.class).retry(1);
StepVerifier.create(responseMono).expectNextCount(1).verifyComplete();
}
but it is giving exception
expectation "expectNextCount(1)" failed (expected: count = 1; actual: counted = 0; signal: onError(org.springframework.web.reactive.function.client.WebClientResponseException$NotFound: 404 Not Found from GET http://localhost:55633/api))
java.lang.AssertionError: expectation "expectNextCount(1)" failed (expected: count = 1; actual: counted = 0; signal: onError(org.springframework.web.reactive.function.client.WebClientResponseException$NotFound: 404 Not Found from GET http://localhost:55633/api))
It is going inside ClientResponseErrorService class but failing at this line clientResponse.bodyToMono(String.class).flatMap(clientResponseBody -> {
Here's an example unit test that tests the scenario when the status code is not an error.
#Test
void doNothing_whenStatus200() {
ClientRequest clientRequest = Mockito.mock(ClientRequest.class);
ExchangeFunction exchangeFunction = Mockito.mock(ExchangeFunction.class);
ClientResponse clientResponse = mock(ClientResponse.class);
given(exchangeFunction.exchange(clientRequest)).willReturn(Mono.just(clientResponse));
given(clientResponse.statusCode()).willReturn(HttpStatus.OK);
ExchangeFilterFunction underTest = ClientResponseErrorService.genericClientFilterFunction();
StepVerifier.create(underTest.filter(clientRequest, exchangeFunction))
.expectNext(clientResponse)
.verifyComplete();
}

Map Error using onErrorMap in WebFlux for Mono<Void>

I've two microservices, let us say a FrontEnd and BackEnd, for FrontEnd I'm using WebFlux and calling backend service using feign client as shown in below code excample, though the below code example works, but I wanted to have a generic exception handler using Function and feed onto onErrorMap
#RestController
#Slf4j
public class MyFrentEndService {
#Autowired
private MyBackEndService client;
#PostMapping(value="/hello", consumes="application/json")
public Mono<Void> sayHello(#Valid String msg) {
log.info("Message is {}", msg);
return Mono.create(sink-> {
try {
client.hello(msg);
}catch (FeignException e) {
System.out.println(e.status());
HttpStatus status = e.status() ==0 ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.valueOf(e.status());
String message = e.getMessage();
sink.error(new ResponseStatusException(status, message));
}
sink.success();
});
}
}
Tried to use onErrorMap, but getting compilation error stating, use Mono instead of Mono<Void>
#RestController
#Slf4j
public class MyFrentEndService {
#Autowired
private MyBackEndService client;
#PostMapping(value="/hello", consumes="application/json")
public Mono<Void> sayHello(#Valid String msg) {
log.info("Message is {}", msg);
return Mono.fromSupplier(() -> {
client.hello(msg);
return null;
}).onErrorMap(e->{
HttpStatus status = e.status() ==0 } HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.valueOf(e.status());
String message = e.getMessage();
return new ResponseStatusException(status, message);
});
}
}
How to use onErrorMap?
This error is unrelated to the operator onErrorMap. This code dont compile because the compiler can not infer the generic type returned by the method Mono.fromSupplier to be Void - you are returning null on the supplied function.
This should be corrected by doing the following:
#PostMapping(value="/hello", consumes="application/json")
public Mono<Void> sayHello(#Valid String msg) {
log.info("Message is {}", msg);
return Mono.<Void>fromSupplier(() -> {
client.hello(msg);
return null;
}).onErrorMap(e->{
HttpStatus status = e.status() ==0 ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.valueOf(e.status());
String message = e.getMessage();
return new ResponseStatusException(status, message);
});
}
I think that it is more idiomatic to do the following:
#PostMapping(value="/hello", consumes="application/json")
public Mono<Void> sayHello(#Valid String msg) {
log.info("Message is {}", msg);
return Mono.fromRunnable(() -> {
client.hello(msg);
})
.then()
.onErrorMap(e->{
HttpStatus status = e.status() ==0 ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.valueOf(e.status());
String message = e.getMessage();
return new ResponseStatusException(status, message);
});
}
Finally, I would advise against using blocking calls inside the reactive pipeline unless you really have to. (prefer WebClient or other nonblocking HTTP client over blocking clients as feign).

ReactiveSecurityContextHolder is empty in Spring WebFlux

I am trying to use the ReactiveSecurityContextHolder with Spring WebFlux. Unfortunately, the SecurityContext is empty :
#Configuration
public class Router {
#Bean
public RouterFunction<ServerResponse> routes(Handler handler) {
return nest(
path("/bill"),
route(
GET("/").and(accept(APPLICATION_JSON)), handler::all));
}
#Component
class Handler {
Mono<ServerResponse> all(ServerRequest request) {
ReactiveSecurityContextHolder.getContext()
.switchIfEmpty(Mono.error(new IllegalStateException("ReactiveSecurityContext is empty")))
.map(SecurityContext::getAuthentication)
.map(Authentication::getName)
.flatMap(s -> Mono.just("Hi " + s))
.subscribe(
System.out::println,
Throwable::printStackTrace,
() -> System.out.println("completed without a value")
);
return ok().build();
}
}
}
This code always throws the IllegalStateException.
If I add a subscriberContext like shown here :
Authentication authentication = new TestingAuthenticationToken("admin", "password", "ROLE_ADMIN");
ReactiveSecurityContextHolder.getContext()
.switchIfEmpty(Mono.error(new IllegalStateException("ReactiveSecurityContext is empty")))
.map(SecurityContext::getAuthentication)
.map(Authentication::getName)
.flatMap(s -> Mono.just("Hi " + s))
.subscriberContext(ReactiveSecurityContextHolder.withAuthentication(authentication))
.subscribe(
System.out::println,
Throwable::printStackTrace,
() -> System.out.println("completed without a value")
);
It works fine and print "Hi admin". But that's not the point, the article says "In a WebFlux application the subscriberContext is automatically setup using ReactorContextWebFilter". So I should be able to fetch the logged user.
I have this configuration :
#EnableWebFluxSecurity
#EnableReactiveMethodSecurity
public class SecurityConfig {
#Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http.authorizeExchange()
.anyExchange().authenticated()
.and().formLogin()
.and().build();
}
#Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, admin);
}
}
Am I missing something here ? If I put breakpoints in ReactorContextWebFilter, I can see that it is correctly called before each request. But my ReactiveSecurityContextHolder is always empty...
You have to return the stream in which you want to access ReactiveSecurityContextHolder. You're not allowed to subscribe within another stream OR you have to do the Reactor context switch manually.
#Component
class Handler {
Mono<ServerResponse> all(ServerRequest request) {
return ReactiveSecurityContextHolder.getContext()
.switchIfEmpty(Mono.error(new IllegalStateException("ReactiveSecurityContext is empty")))
.map(SecurityContext::getAuthentication)
.map(Authentication::getName)
.flatMap(s -> Mono.just("Hi " + s))
.doOnNext(System.out::println)
.doOnError(Throwable::printStackTrace)
.doOnSuccess(s -> System.out.println("completed without value: " + s))
.flatMap(s -> ServerResponse.ok().build());
}
}

Resources