Initial commit

This commit is contained in:
2023-09-23 08:48:45 +02:00
commit 9bade2780c
77 changed files with 4249 additions and 0 deletions
@@ -0,0 +1,35 @@
package de.twomartens.template;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@SpringBootApplication
public class MainApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(MainApplication.class);
}
public static void main(String[] args) {
SpringApplication.run(MainApplication.class, args);
}
@Bean
public OpenAPI customOpenAPI(@Value("${openapi.description}") String apiDesciption,
@Value("${openapi.version}") String apiVersion, @Value("${openapi.title}") String apiTitle) {
return new OpenAPI()
.info(new Info()
.title(apiTitle)
.version(apiVersion)
.description(apiDesciption));
}
}
@@ -0,0 +1,20 @@
package de.twomartens.template.configuration;
import de.twomartens.template.property.ServiceProperties;
import java.time.Clock;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
public class ClockConfiguration {
private final ServiceProperties serviceProperties;
@Bean
public Clock clock() {
return Clock.system(serviceProperties.getDefaultTimeZone());
}
}
@@ -0,0 +1,22 @@
package de.twomartens.template.configuration;
import de.twomartens.template.interceptor.HeaderInterceptorRest;
import de.twomartens.template.interceptor.LoggingInterceptorRest;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class InterceptorConfiguration {
@Bean
public LoggingInterceptorRest loggingInterceptorRest(Clock clock) {
return new LoggingInterceptorRest(clock);
}
@Bean
public HeaderInterceptorRest headerInterceptorRest() {
return new HeaderInterceptorRest();
}
}
@@ -0,0 +1,18 @@
package de.twomartens.template.configuration;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfiguration {
@Bean
public GroupedOpenApi swaggerApi10() {
return GroupedOpenApi.builder()
.group("1.0")
.pathsToMatch("/template/v1/**")
.build();
}
}
@@ -0,0 +1,34 @@
package de.twomartens.template.configuration;
import de.twomartens.template.interceptor.HeaderInterceptorRest;
import de.twomartens.template.interceptor.LoggingInterceptorRest;
import de.twomartens.template.property.RestTemplateTimeoutProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfiguration {
@Bean
public RestTemplate restTemplate(HeaderInterceptorRest headerInterceptorRest,
LoggingInterceptorRest loggingInterceptor,
RestTemplateTimeoutProperties restTemplateTimeoutProperties) {
return new RestTemplateBuilder()
.additionalInterceptors(headerInterceptorRest, loggingInterceptor)
.setConnectTimeout(restTemplateTimeoutProperties.getConnectionRestTemplateTimeoutInMillis())
.setReadTimeout(restTemplateTimeoutProperties.getReadTimeoutRestTemplateInMillis())
.build();
}
@Bean
public RestTemplate restTemplateRestHealthIndicator(HeaderInterceptorRest headerInterceptorRest,
RestTemplateTimeoutProperties restTemplateTimeoutProperties) {
return new RestTemplateBuilder()
.additionalInterceptors(headerInterceptorRest)
.setConnectTimeout(restTemplateTimeoutProperties.getConnectionRestHealthIndicatorTimeoutInMillis())
.setReadTimeout(restTemplateTimeoutProperties.getReadTimeoutRestHealthIndicatorInMillis())
.build();
}
}
@@ -0,0 +1,28 @@
package de.twomartens.template.configuration;
import de.twomartens.template.monitoring.statusprobe.CountBasedStatusProbe;
import de.twomartens.template.monitoring.statusprobe.StatusProbe;
import de.twomartens.template.monitoring.statusprobe.StatusProbeCriticality;
import de.twomartens.template.monitoring.statusprobe.StatusProbeLogger;
import java.time.Clock;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
public class StatusProbeConfiguration {
private final Clock clock;
@Bean
public StatusProbeLogger statusProbeLogger() {
return new StatusProbeLogger(clock);
}
@Bean
public StatusProbe testStatusProbe(StatusProbeLogger statusProbeLogger) {
return new CountBasedStatusProbe(1,
clock, StatusProbeCriticality.K1, "testStatusProbe", statusProbeLogger);
}
}
@@ -0,0 +1,28 @@
package de.twomartens.template.configuration;
import de.twomartens.template.interceptor.HeaderInterceptorRest;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfiguration implements WebMvcConfigurer {
private final HeaderInterceptorRest headerInterceptorRest;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(headerInterceptorRest);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
CorsRegistration registration = registry.addMapping("/**");
registration.allowedOrigins("*");
}
}
@@ -0,0 +1,37 @@
package de.twomartens.template.controller;
import de.twomartens.template.exception.HttpStatusException;
import de.twomartens.template.model.dto.ErrorMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@ControllerAdvice(annotations = RestController.class)
@Slf4j
public class ExceptionController extends ResponseEntityExceptionHandler {
@ExceptionHandler(HttpStatusException.class)
public ResponseEntity<ErrorMessage> handleException(HttpStatusException e) {
if (e.getCause() != null) {
log.info(e.getCause().toString(), e.getCause());
} else {
log.info(e.toString());
}
return ResponseEntity.status(e.getStatus()).body(ErrorMessage.builder()
.message(e.getMessage())
.build());
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorMessage> handleRuntimeException(RuntimeException e) {
log.error("unexpected exception occurred", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorMessage.builder()
.message(e.getMessage())
.build());
}
}
@@ -0,0 +1,95 @@
package de.twomartens.template.controller;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.jar.JarFile;
import java.util.zip.ZipEntry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* used to show version and title information on html pages
*/
@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping(value = "/vorlage")
public class VersionHtmlController {
@GetMapping(path = "/html/version.html")
public String version() {
return "version";
}
@ControllerAdvice
public static class VersionControllerAdvice {
@ModelAttribute("version")
public String getApplicationVersion() {
return getTitle() + " " + getVersion();
}
@ModelAttribute("footerString")
public String getApplicationVersion(@RequestHeader("host") String hostName) {
return getTitle() + " " + getVersion() + " - " + hostName;
}
private String getTitle() {
return Optional.ofNullable(VersionControllerAdvice.class.getPackage().getImplementationTitle())
.filter(s -> !s.isBlank())
.orElse("application");
}
public String getVersion() {
return Optional.ofNullable(VersionControllerAdvice.class.getPackage().getImplementationVersion())
.filter(s -> !s.isBlank())
.orElse("DEVELOPER");
}
@ModelAttribute("hostname")
public String getHostname() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (UnknownHostException e) {
log.warn(e.toString(), e);
}
return "";
}
@ModelAttribute("manifest")
private Collection<String> getManifest() {
try {
URL location = getClass().getProtectionDomain().getCodeSource().getLocation();
String jarFileName = Paths.get(location.toURI()).toString();
try (JarFile jarFile = new JarFile(jarFileName)) {
ZipEntry entry = jarFile.getEntry(JarFile.MANIFEST_NAME);
try (InputStream in = jarFile.getInputStream(entry)) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8).lines().toList();
}
}
} catch (FileNotFoundException ignored) {
// do nothing if manifest file is not available
} catch (Exception e) {
log.info(e.toString(), e);
}
return List.of(getTitle() + " " + getVersion());
}
}
}
@@ -0,0 +1,59 @@
package de.twomartens.template.controller.v1;
import de.twomartens.template.mapper.v1.GreetingMapper;
import de.twomartens.template.model.dto.v1.Greeting;
import de.twomartens.template.service.GreetingService;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.mapstruct.factory.Mappers;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/template/v1")
@Tag(name = "Greeting Example", description = "all requests relating to greetings")
public class GreetingRestController {
private final GreetingMapper mapper = Mappers.getMapper(GreetingMapper.class);
private final GreetingService service;
@Operation(
summary = "Returns a greeting message",
responses = {@ApiResponse(
responseCode = "200")
}
)
@GetMapping("/greeting")
public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
return mapper.map(service.createGreeting(name));
}
@Operation(
summary = "Posts a greeting message to db",
responses = {@ApiResponse(
responseCode = "200")
}
)
@PostMapping("/greeting")
public void postGreeting(@RequestBody Greeting greeting) {
service.postGreeting(mapper.map(greeting));
}
@Hidden
@GetMapping("/healthCheck")
public String checkHealth(@RequestParam(value = "message") String message) {
return message;
}
}
@@ -0,0 +1,20 @@
package de.twomartens.template.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class HttpStatusException extends RuntimeException {
private final HttpStatus status;
public HttpStatusException(HttpStatus status, String message) {
super(message);
this.status = status;
}
public HttpStatusException(HttpStatus status, String message, Throwable cause) {
super(message, cause);
this.status = status;
}
}
@@ -0,0 +1,82 @@
/*
* Copyright 2002-2017 the original author or authors.
*
* 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
*
* https://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 de.twomartens.template.interceptor;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.util.StreamUtils;
/**
* Simple implementation of {@link ClientHttpResponse} that reads the response's body into memory, thus allowing for
* multiple invocations of {@link #getBody()}.
*
* @author Arjen Poutsma
* @since 3.1
*/
public final class BufferingClientHttpResponseWrapper implements ClientHttpResponse {
private final ClientHttpResponse response;
@Nullable
private byte[] body;
public BufferingClientHttpResponseWrapper(ClientHttpResponse response) {
this.response = response;
}
@Override
public HttpStatusCode getStatusCode() throws IOException {
return this.response.getStatusCode();
}
@Override
@Deprecated
public int getRawStatusCode() throws IOException {
return this.response.getRawStatusCode();
}
@Override
public String getStatusText() throws IOException {
return this.response.getStatusText();
}
@Override
public HttpHeaders getHeaders() {
return this.response.getHeaders();
}
@Override
public InputStream getBody() throws IOException {
if (this.body == null) {
this.body = StreamUtils.copyToByteArray(this.response.getBody());
}
return new ByteArrayInputStream(this.body);
}
@Override
public void close() {
this.response.close();
}
}
@@ -0,0 +1,87 @@
package de.twomartens.template.interceptor;
import java.io.Closeable;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.MDC;
public abstract class HeaderInterceptor {
public static final String LOGGER_TRACE_ID = "trace.id";
public static final String LOGGER_REQTYPE_ID = "REQTYPE";
public static final String HEADER_FIELD_TRACE_ID = "X-TraceId";
public static final String HEADER_FIELD_B3_TRACE_ID = "x-b3-traceid";
public static final String HEADER_FIELD_TYPE_ID = "x-type";
public static final String REQ_TYPE_HEALTHCHECK = "HEALTH_CHECK";
public static final String REQ_TYPE_INTEGRATION_TEST = "INTEGRATION_TEST";
public static final String REQ_TYPE_SERVER_TEST = "SERVER_TEST";
public static final String REQ_TYPE_WARMUP = "WARMUP";
public String getTraceId() {
return Optional.ofNullable(MDC.get(LOGGER_TRACE_ID))
.filter(s -> !s.isBlank())
.orElse(createNewTraceId());
}
public Optional<String> getRequestType() {
return Optional.ofNullable(MDC.get(LOGGER_REQTYPE_ID))
.filter(requestType -> !requestType.isBlank());
}
public InterceptorCloseables setTraceId(String traceId) {
return new InterceptorCloseables(MDC.putCloseable(LOGGER_TRACE_ID, traceId));
}
public InterceptorCloseables mark(String requestType) {
return new InterceptorCloseables(MDC.putCloseable(LOGGER_REQTYPE_ID, requestType));
}
public InterceptorCloseables set(String traceId, String requestType) {
return requestType != null
? new InterceptorCloseables(setTraceId(traceId), mark(requestType))
: setTraceId(traceId);
}
public InterceptorCloseables markAsHealthCheck() {
return new InterceptorCloseables(mark(REQ_TYPE_HEALTHCHECK), setTraceId(createNewTraceId()));
}
public InterceptorCloseables markAsIntegrationTest() {
return new InterceptorCloseables(mark(REQ_TYPE_INTEGRATION_TEST), setTraceId(createNewTraceId()));
}
public InterceptorCloseables markAsServerTest() {
return new InterceptorCloseables(mark(REQ_TYPE_SERVER_TEST), setTraceId(createNewTraceId()));
}
public InterceptorCloseables markAsWarmup() {
return new InterceptorCloseables(mark(REQ_TYPE_WARMUP), setTraceId(createNewTraceId()));
}
private static String createNewTraceId() {
return UUID.randomUUID().toString();
}
public static class InterceptorCloseables implements Closeable {
private final Closeable[] closeables;
private InterceptorCloseables(Closeable... closeables) {
this.closeables = closeables;
}
@Override
public void close() {
for (Closeable closeable : closeables) {
try {
closeable.close();
} catch (Exception ignored) {
// do nothing
}
}
}
}
}
@@ -0,0 +1,68 @@
package de.twomartens.template.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
public class HeaderInterceptorRest extends HeaderInterceptor
implements HandlerInterceptor, ClientHttpRequestInterceptor {
public static final String CLASS_NAME = HeaderInterceptorRest.class.getName();
// ClientHttpRequestInterceptor
@Override
@NonNull
public ClientHttpResponse intercept(HttpRequest request, @NonNull byte[] body,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().add(HEADER_FIELD_TRACE_ID, getTraceId());
getRequestType().ifPresent(requestType -> request.getHeaders().add(HEADER_FIELD_TYPE_ID, requestType));
try {
return execution.execute(request, body);
} finally {
request.getHeaders().remove(HEADER_FIELD_TRACE_ID);
request.getHeaders().remove(HEADER_FIELD_TYPE_ID);
}
}
// HandlerInterceptor
@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull Object handler) {
String traceId = extractTraceId(request);
String requestType = extractRequestType(request);
InterceptorCloseables closeable = set(traceId, requestType);
request.setAttribute(CLASS_NAME, closeable);
return true;
}
public static String extractTraceId(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(HEADER_FIELD_TRACE_ID)).filter(s -> !s.isBlank())
.or(() -> Optional.ofNullable(request.getHeader(HEADER_FIELD_B3_TRACE_ID)).filter(s -> !s.isBlank()))
.orElse(UUID.randomUUID().toString());
}
public static String extractRequestType(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(HEADER_FIELD_TYPE_ID))
.filter(reqType -> !reqType.isBlank())
.orElse(null);
}
// HandlerInterceptor
@Override
public void postHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull Object handler, ModelAndView modelAndView) {
Optional.ofNullable(request.getAttribute(CLASS_NAME))
.filter(InterceptorCloseables.class::isInstance)
.map(InterceptorCloseables.class::cast)
.ifPresent(InterceptorCloseables::close);
}
}
@@ -0,0 +1,419 @@
package de.twomartens.template.interceptor;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.Builder;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.logging.log4j.Marker;
import org.apache.logging.log4j.MarkerManager.Log4jMarker;
import org.apache.logging.log4j.message.StringMapMessage;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.NonNull;
import org.springframework.util.StreamUtils;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
@Log4j2
@RequiredArgsConstructor
public class LoggingInterceptorRest implements Filter, ClientHttpRequestInterceptor {
public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
public static final Marker MARKER = new Log4jMarker("communication");
private static final int MAX_LOG_SIZE = 20480; // 20 KB - logging could fail with bigger logmessages
public static final String DIRECTION_IN = "inbound";
public static final String DIRECTION_OUT = "outbound";
public static final String PROTOCOL_NAME = "http";
public static final String PARAM_URL_FULL = "url.full";
public static final String PARAM_URL_DOMAIN = "url.domain";
public static final String PARAM_URL_EXTENSION = "url.extension";
public static final String PARAM_URL_PATH = "url.path";
public static final String PARAM_URL_PORT = "url.port";
public static final String PARAM_URL_SCHEME = "url.scheme";
public static final String PARAM_URL_QUERY = "url.query";
public static final String PARAM_BUSINESS_TYPE = "http.request.type";
public static final String PARAM_REQUEST_BODY = "http.request.body.content";
public static final String PARAM_RESPONSE_BODY = "http.response.body.content";
public static final String PARAM_RESPONSE_STATUS = "http.response.status_code";
public static final String PARAM_REQUEST_HEADERS = "http.request.headers";
public static final String PARAM_RESPONSE_HEADERS = "http.response.headers";
public static final String PARAM_REQUEST_BYTES = "http.request.body.bytes";
public static final String PARAM_RESPONSE_BYTES = "http.response.body.bytes";
public static final String PARAM_REQUEST_MIMETYPE = "http.request.mime_type";
public static final String PARAM_RESPONSE_MIMETYPE = "http.response.mime_type";
public static final String PARAM_REQUEST_METHOD = "http.request.method";
public static final String PARAM_REQUEST_REFERER = "http.request.referrer";
public static final String PARAM_REQUEST_TIME = "event.start";
public static final String PARAM_RESPONSE_TIME = "event.end";
public static final String PARAM_DURATION = "event.duration";
public static final String PARAM_USER_AGENT = "user_agent.original";
public static final String PARAM_DIRECTION = "network.direction";
public static final String PARAM_PROTOCOL = "network.protocol";
private final FieldLogBehaviour requestLogBehaviour;
private final FieldLogBehaviour responseLogBehaviour;
private final Clock clock;
public LoggingInterceptorRest(Clock clock) {
this(FieldLogBehaviour.NEVER, FieldLogBehaviour.NEVER, clock);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
ZonedDateTime requestTime = ZonedDateTime.now(clock);
HttpServletRequest httpRequest = (HttpServletRequest) request;
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(httpRequest);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(
(HttpServletResponse) response);
byte[] fullResponseBytes = null;
Throwable throwable = null;
String responseBody = null;
int httpStatusCode = -1;
try {
try {
chain.doFilter(
requestLogBehaviour != FieldLogBehaviour.NEVER ? requestWrapper : httpRequest,
responseWrapper);
if (responseLogBehaviour != FieldLogBehaviour.NEVER) {
fullResponseBytes = responseWrapper.getContentAsByteArray();
}
httpStatusCode = responseWrapper.getStatus();
} finally {
responseWrapper.copyBodyToResponse();
}
} catch (Exception e) {
throwable = e;
throw e;
} finally {
try {
int responseSize = responseWrapper.getContentSize();
Map<String, Collection<String>> responseHeaders = extractHeaders(
responseWrapper.getHeaderNames().iterator(),
s -> responseWrapper.getHeaders(s).iterator());
if ((responseLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| responseLogBehaviour == FieldLogBehaviour.ALWAYS) && fullResponseBytes != null) {
responseBody = new String(fullResponseBytes,
determineResponseEncoding(responseHeaders, fullResponseBytes));
}
URL requestUrl = new URL(Optional.ofNullable(httpRequest.getQueryString())
.map(qs -> httpRequest.getRequestURL().toString() + "?" + qs)
.orElse(httpRequest.getRequestURL().toString()));
Map<String, Collection<String>> requestHeaders = extractHeaders(
httpRequest.getHeaderNames().asIterator(),
s -> httpRequest.getHeaders(s).asIterator());
String requestBody = null;
String businessType = null;
if (requestLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| requestLogBehaviour == FieldLogBehaviour.ALWAYS) {
byte[] fullRequestBytes = requestWrapper.getContentAsByteArray();
if (fullRequestBytes != null) {
requestBody = new String(fullRequestBytes,
determineRequestEncoding(requestHeaders, fullRequestBytes));
}
businessType = determineBusinessType(requestUrl, requestBody);
}
log(LogMessage.builder()
.requestHeaders(requestHeaders)
.responseHeaders(responseHeaders)
.url(requestUrl)
.method(httpRequest.getMethod())
.requestMimeType(typeToString(request.getContentType()))
.responseMimeType(typeToString(response.getContentType()))
.requestBody(requestBody)
.responseBody(responseBody)
.requestSize(httpRequest.getContentLength())
.responseSize(responseSize)
.httpStatus(httpStatusCode)
.direction(DIRECTION_IN)
.requestTime(requestTime)
.responseTime(ZonedDateTime.now(clock))
.traceId(HeaderInterceptorRest.extractTraceId(httpRequest))
.requestType(HeaderInterceptorRest.extractRequestType(httpRequest))
.businessType(businessType)
.throwable(throwable)
.build());
} catch (RuntimeException e) {
log.error(e.toString(), e);
}
}
}
private boolean isError(int httpStatusCode) {
return httpStatusCode >= 400 && httpStatusCode < 600;
}
@NonNull
@Override
public ClientHttpResponse intercept(@NonNull HttpRequest request,
@NonNull byte[] requestBytes, @NonNull ClientHttpRequestExecution execution)
throws IOException {
ZonedDateTime requestTime = ZonedDateTime.now(clock);
int responseSize = 0;
Map<String, Collection<String>> responseHeaders = Collections.emptyMap();
MediaType responseMediaType = null;
int httpStatusCode = -1;
Throwable throwable = null;
String requestBody = null;
String responseBody = null;
String businessType = null;
try {
BufferingClientHttpResponseWrapper result = new BufferingClientHttpResponseWrapper(
execution.execute(request, requestBytes));
byte[] responseBytes = StreamUtils.copyToByteArray(result.getBody());
responseSize = responseBytes.length;
responseHeaders = extractHeaders(result.getHeaders());
responseMediaType = result.getHeaders().getContentType();
httpStatusCode = result.getStatusCode().value();
if (responseLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| responseLogBehaviour == FieldLogBehaviour.ALWAYS) {
responseBody = new String(responseBytes,
determineRequestEncoding(responseHeaders, responseBytes));
}
return result;
} catch (Exception e) {
throwable = e;
throw e;
} finally {
try {
URL url = request.getURI().toURL();
Map<String, Collection<String>> requestHeaders = extractHeaders(request.getHeaders());
if (requestLogBehaviour == FieldLogBehaviour.ONLY_ON_ERROR && isError(httpStatusCode)
|| requestLogBehaviour == FieldLogBehaviour.ALWAYS) {
requestBody = new String(requestBytes,
determineRequestEncoding(requestHeaders, requestBytes));
businessType = determineBusinessType(url, requestBody);
}
log(LogMessage.builder()
.requestHeaders(requestHeaders)
.responseHeaders(responseHeaders)
.url(url)
.method(Objects.requireNonNull(request.getMethod()).toString())
.requestMimeType(typeToString(request.getHeaders().getContentType()))
.requestBody(requestBody)
.responseBody(responseBody)
.responseMimeType(typeToString(responseMediaType))
.requestSize(requestBytes.length)
.responseSize(responseSize)
.httpStatus(httpStatusCode)
.direction(DIRECTION_OUT)
.requestTime(requestTime)
.responseTime(ZonedDateTime.now(clock))
.businessType(businessType)
.throwable(throwable)
.build());
} catch (RuntimeException e) {
log.error(e.toString(), e);
}
}
}
private void log(LogMessage logMessage) {
StringMapMessage stringMapMessage = new StringMapMessage();
addLogString(stringMapMessage, PARAM_REQUEST_HEADERS,
toHeaderString(logMessage.requestHeaders()));
addLogString(stringMapMessage, PARAM_RESPONSE_HEADERS,
toHeaderString(logMessage.responseHeaders()));
addLogString(stringMapMessage, PARAM_URL_FULL, logMessage.url().toString());
addLogString(stringMapMessage, PARAM_URL_DOMAIN, logMessage.url().getHost());
addLogString(stringMapMessage, PARAM_URL_EXTENSION,
extractExtension(logMessage.url().getPath()));
addLogString(stringMapMessage, PARAM_URL_PATH, logMessage.url().getPath());
addLogString(stringMapMessage, PARAM_URL_PORT, Integer.toString(logMessage.url().getPort()));
addLogString(stringMapMessage, PARAM_URL_SCHEME, logMessage.url().getProtocol());
addLogString(stringMapMessage, PARAM_URL_QUERY, logMessage.url().getQuery());
addLogString(stringMapMessage, PARAM_REQUEST_METHOD, logMessage.method());
addLogString(stringMapMessage, PARAM_REQUEST_REFERER,
getHeader(logMessage.requestHeaders(), HttpHeaders.REFERER));
addLogString(stringMapMessage, PARAM_REQUEST_MIMETYPE, logMessage.requestMimeType());
addLogString(stringMapMessage, PARAM_RESPONSE_MIMETYPE, logMessage.responseMimeType());
addLogString(stringMapMessage, PARAM_REQUEST_BYTES, Integer.toString(logMessage.requestSize()));
addLogString(stringMapMessage, PARAM_RESPONSE_BYTES,
Integer.toString(logMessage.responseSize()));
addLogString(stringMapMessage, PARAM_RESPONSE_STATUS,
Integer.toString(logMessage.httpStatus()));
addLogString(stringMapMessage, PARAM_DIRECTION, logMessage.direction());
addLogString(stringMapMessage, PARAM_PROTOCOL, PROTOCOL_NAME);
addLogString(stringMapMessage, PARAM_REQUEST_TIME,
logMessage.requestTime().format(DATE_TIME_FORMATTER));
addLogString(stringMapMessage, PARAM_RESPONSE_TIME,
logMessage.responseTime().format(DATE_TIME_FORMATTER));
addLogString(stringMapMessage, PARAM_DURATION,
Long.toString(getDurationBetweenRequestAndResponseTime(logMessage).toNanos()));
addLogString(stringMapMessage, PARAM_USER_AGENT,
getHeader(logMessage.requestHeaders(), HttpHeaders.USER_AGENT));
addLogString(stringMapMessage, HeaderInterceptor.LOGGER_TRACE_ID, logMessage.traceId());
addLogString(stringMapMessage, HeaderInterceptor.LOGGER_REQTYPE_ID, logMessage.requestType());
addLogString(stringMapMessage, PARAM_BUSINESS_TYPE, logMessage.businessType());
addLogString(stringMapMessage, PARAM_REQUEST_BODY, cutToMaxLength(logMessage.requestBody()));
addLogString(stringMapMessage, PARAM_RESPONSE_BODY, cutToMaxLength(logMessage.responseBody()));
log.debug(MARKER, stringMapMessage, logMessage.throwable());
}
private Duration getDurationBetweenRequestAndResponseTime(LogMessage logMessage) {
return Duration.between(logMessage.requestTime(), logMessage.responseTime());
}
private String getHeader(Map<String, Collection<String>> headers, String headerKey) {
return headers.entrySet().stream()
.filter(e -> e.getKey().equalsIgnoreCase(headerKey))
.flatMap(e -> e.getValue().stream())
.findAny()
.orElse(null);
}
private void addLogString(StringMapMessage stringMapMessage, String key, String value) {
if (value != null && !value.isBlank()) {
stringMapMessage.with(key, value.trim());
}
}
private static Map<String, Collection<String>> extractHeaders(Iterator<String> headerNames,
Function<String, Iterator<String>> headerValuesSupplier) {
Map<String, Collection<String>> requestHeaders = new HashMap<>();
while (headerNames.hasNext()) {
String name = headerNames.next();
Collection<String> values = requestHeaders.computeIfAbsent(name, n -> new TreeSet<>());
Iterator<String> headerValues = headerValuesSupplier.apply(name);
while (headerValues.hasNext()) {
values.add(headerValues.next());
}
}
return requestHeaders;
}
private static Map<String, Collection<String>> extractHeaders(HttpHeaders headers) {
Map<String, Collection<String>> result = new HashMap<>();
for (Entry<String, List<String>> entry : headers.entrySet()) {
result.put(entry.getKey(), new ArrayList<>(entry.getValue()));
}
return result;
}
private static String toHeaderString(Map<String, Collection<String>> headerMap) {
return headerMap.entrySet().stream()
.flatMap(es -> es.getValue().stream().map(v -> new KeyValuePair(es.getKey(), v)))
.map(kv -> kv.key() + "=" + kv.value())
.collect(Collectors.joining("\n"));
}
private static String typeToString(String contentType) {
try {
return Optional.ofNullable(contentType)
.map(MediaType::parseMediaType)
.map(LoggingInterceptorRest::typeToString)
.orElse(null);
} catch (RuntimeException e) {
log.info(e.toString(), e);
return e.toString();
}
}
private static String typeToString(MediaType mediaType) {
try {
return Optional.ofNullable(mediaType)
.map(m -> m.getType() + "/" + m.getSubtype())
.orElse(null);
} catch (RuntimeException e) {
log.info(e.toString(), e);
return e.toString();
}
}
private static String extractExtension(String fileName) {
return Optional.ofNullable(fileName)
.filter(name -> name.contains("."))
.map(name -> name.substring(name.lastIndexOf('.') + 1))
.orElse(null);
}
private static String cutToMaxLength(String string) {
if (string != null && string.length() > MAX_LOG_SIZE) {
return string.substring(0, MAX_LOG_SIZE);
}
return string;
}
/**
* usually returns null, but can be overridden to implement more complex logic
*/
public String determineBusinessType(URL requestUrl, String requestBody) {
return null;
}
/**
* usually returns UTF-8, but can be overridden to implement more complex logic
*/
public Charset determineRequestEncoding(Map<String, Collection<String>> requestHeaders,
byte[] fullRequest) {
return StandardCharsets.UTF_8;
}
/**
* usually returns UTF-8, but can be overridden to implement more complex logic
*/
public Charset determineResponseEncoding(Map<String, Collection<String>> responseHeaders,
byte[] fullResponse) {
return StandardCharsets.UTF_8;
}
@Builder
private record LogMessage(Map<String, Collection<String>> requestHeaders,
Map<String, Collection<String>> responseHeaders, URL url, String method,
String requestMimeType, String responseMimeType, String requestBody,
String responseBody,
int requestSize, int responseSize, int httpStatus, String direction,
ZonedDateTime requestTime, ZonedDateTime responseTime, String traceId,
String requestType,
String businessType, Throwable throwable) {
}
private record KeyValuePair(String key, String value) {
}
public enum FieldLogBehaviour {
NEVER, ONLY_ON_ERROR, ALWAYS
}
}
@@ -0,0 +1,21 @@
package de.twomartens.template.mapper.v1;
import de.twomartens.template.model.db.Greeting;
import org.mapstruct.CollectionMappingStrategy;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.NullValueCheckStrategy;
import org.mapstruct.ReportingPolicy;
@Mapper(collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS,
unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface GreetingMapper {
de.twomartens.template.model.dto.v1.Greeting map(Greeting greeting);
@Mapping(target = "id", ignore = true)
@Mapping(target = "created", ignore = true)
@Mapping(target = "lastModified", ignore = true)
Greeting map(de.twomartens.template.model.dto.v1.Greeting greeting);
}
@@ -0,0 +1,41 @@
package de.twomartens.template.model.db;
import java.util.Date;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldDefaults;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.mongodb.core.mapping.Document;
@Document
@Getter
@Setter
@Builder
@EqualsAndHashCode
@ToString
@FieldDefaults(level = AccessLevel.PRIVATE)
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Greeting {
@Id
ObjectId id;
@CreatedDate
Date created;
@LastModifiedDate
Date lastModified;
String message;
}
@@ -0,0 +1,24 @@
package de.twomartens.template.model.dto;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldDefaults;
@Getter
@Setter
@Builder
@EqualsAndHashCode
@ToString
@FieldDefaults(level = AccessLevel.PRIVATE)
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ErrorMessage {
String message;
}
@@ -0,0 +1,31 @@
package de.twomartens.template.model.dto.v1;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldDefaults;
import org.springframework.lang.NonNull;
@Getter
@Setter
@Builder
@EqualsAndHashCode
@ToString
@FieldDefaults(level = AccessLevel.PRIVATE)
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Schema(description = "Data container for a greeting message")
public class Greeting {
@NonNull
@Schema(description = "Data container for a greeting message", example = "Hello Helmut!",
defaultValue = "Hello World!")
private final String message;
}
@@ -0,0 +1,84 @@
package de.twomartens.template.monitoring.actuator;
import java.io.Closeable;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.slf4j.MDC.MDCCloseable;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.actuate.health.Status;
@Slf4j
@RequiredArgsConstructor
public abstract class AbstractHealthIndicator implements HealthIndicator {
public static final String HOST = "localhost";
public static final String HTTP_PREFIX = "http://";
public static final String HOST_PORT_SEPERATOR = ":";
public static final String PATH_SEPERATOR = "/";
public static final String PARAMETER_SEPERATOR = "?";
public static final String DETAIL_ENDPOINT_KEY = "endpoint";
private final String logStatusDownMessage = String.format("health indicator '%s' invoked with status '%s'",
indicatorName(), Status.DOWN.getCode());
private final String logStatusUpMessage = String.format("health indicator '%s' invoked with status '%s'",
indicatorName(), Status.UP.getCode());
private final Clock clock;
private final Preparable preparable;
private boolean firstTime = true;
/**
* main method that determines the health of the service
*/
protected abstract Health determineHealth();
@Override
public Health health() {
try (Closeable ignored = preparable.prepare()) {
Health result = null;
Exception exception = null;
long start = clock.millis();
try {
result = determineHealth();
} catch (RuntimeException e) {
exception = e;
result = Health.down().withException(e).build();
} finally {
logInvocation(result, exception, start, clock.millis());
}
return result;
} catch (IOException e) {
log.error("unexpected exception occurred", e);
return Health.down(e).build();
}
}
private void logInvocation(Health health, Exception exception, long start, long end) {
Duration duration = Duration.ofMillis(end - start);
try (MDCCloseable ignored = MDC.putCloseable("event.duration", Long.toString(duration.toNanos()))) {
if (exception != null || health == null) {
log.error(logStatusDownMessage, exception);
firstTime = true;
} else if (health.getStatus() == Status.DOWN) {
log.warn(logStatusDownMessage);
firstTime = true;
} else if (firstTime) {
log.info(logStatusUpMessage);
firstTime = false;
} else {
log.trace(logStatusUpMessage);
}
}
}
private String indicatorName() {
return this.getClass().getSimpleName()
.replace("HealthIndicator", "")
.toLowerCase();
}
}
@@ -0,0 +1,37 @@
package de.twomartens.template.monitoring.actuator;
import de.twomartens.template.monitoring.statusprobe.StatusProbe;
import java.time.Clock;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.Health.Builder;
import org.springframework.boot.actuate.health.HealthIndicator;
@Slf4j
public abstract class AbstractStatusProbeHealthIndicator extends AbstractHealthIndicator
implements HealthIndicator {
public static final String MESSAGE_KEY = "message";
public static final String LAST_STATUS_CHANGE_KEY = "lastStatusChange";
private final StatusProbe statusProbe;
public AbstractStatusProbeHealthIndicator(Clock timeProvider, Preparable headerInterceptor,
StatusProbe statusProbe) {
super(timeProvider, headerInterceptor);
this.statusProbe = statusProbe;
}
@Override
protected Health determineHealth() {
Builder healthBuilder = Health.status(statusProbe.getStatus());
Optional.ofNullable(statusProbe.getLastStatusChange())
.ifPresent(l -> healthBuilder.withDetail(LAST_STATUS_CHANGE_KEY, l));
Optional.ofNullable(statusProbe.getThrowable()).ifPresent(healthBuilder::withException);
Optional.ofNullable(statusProbe.getMessage())
.ifPresent(m -> healthBuilder.withDetail(MESSAGE_KEY, m));
return healthBuilder.build();
}
}
@@ -0,0 +1,10 @@
package de.twomartens.template.monitoring.actuator;
import java.io.Closeable;
@FunctionalInterface
public interface Preparable {
Closeable prepare();
}
@@ -0,0 +1,65 @@
package de.twomartens.template.monitoring.actuator;
import de.twomartens.template.interceptor.HeaderInterceptorRest;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.actuate.health.Status;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
/**
* A Health check which checks if the rest services are working.
* <p>
* If you have a complex service, you should think about an easy greeting or echo service, which
* only tests the
* network/service stack and not the full application.
* <p>
* The health check will be called by kubernetes to check if the container/pod should be in load
* balancing. It is possible
* to have as much health checks as you like.
* <p>
* There should be a health check which is ok not before all data is loaded.
*/
@Slf4j
@Component
public class RestHealthIndicator extends AbstractHealthIndicator implements HealthIndicator {
private static final String URL_PATH = "/template/v1/healthCheck";
private static final String GET_PARAMETER = "message=";
private final SecureRandom randomizer = new SecureRandom();
private final RestTemplate restTemplateRestHealthIndicator;
private final String urlPrefix;
public RestHealthIndicator(Clock clock, HeaderInterceptorRest interceptor,
ServerProperties serverProperties, RestTemplate restTemplateRestHealthIndicator) {
super(clock, interceptor::markAsHealthCheck);
this.restTemplateRestHealthIndicator = restTemplateRestHealthIndicator;
urlPrefix = HTTP_PREFIX + HOST + HOST_PORT_SEPERATOR + serverProperties.getPort()
+ URL_PATH + PARAMETER_SEPERATOR + GET_PARAMETER;
}
/**
* main method that determines the health of the service
*/
@Override
protected Health determineHealth() {
String random = Integer.toString(randomizer.nextInt(100000, 999999));
String url = urlPrefix + "{random}";
ResponseEntity<String> response = restTemplateRestHealthIndicator.getForEntity(url, String.class, random);
Status status = Optional.ofNullable(response.getBody())
.filter(random::equals)
.map(m -> Status.UP)
.orElse(Status.DOWN);
return Health.status(status)
.withDetail(DETAIL_ENDPOINT_KEY, url)
.build();
}
}
@@ -0,0 +1,33 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Clock;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.boot.actuate.health.Status;
public class CountBasedStatusProbe extends StatusProbe {
private final AtomicInteger failureCount = new AtomicInteger(0);
private final int maxFailureCount;
public CountBasedStatusProbe(int maxFailureCount, Clock clock, StatusProbeCriticality criticality, String name,
StatusProbeLogger statusProbeLogger) {
super(clock, criticality, name, statusProbeLogger);
this.maxFailureCount = maxFailureCount;
}
@Override
protected synchronized void setStatus(Status status, Throwable throwable, String message) {
if (status == Status.DOWN) {
int failureCount = this.failureCount.incrementAndGet();
if (failureCount > maxFailureCount) {
super.setStatus(status, throwable, message);
}
} else if (status == Status.UP) {
this.failureCount.set(0);
super.setStatus(status, throwable, message);
}
}
}
@@ -0,0 +1,63 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Clock;
import java.time.Duration;
import org.springframework.boot.actuate.health.Status;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
/**
* uses the percentage of down statuses within a given period (default: 1 min) to determine if status of probe is down.
* this is meant to be used to avoid flickering status probes on services that have lots of status updates. When there
* is no significant amount of requests during one scheduling period, the behavior may be arbitrary.
*/
public class PercentageBasedStatusProbe extends StatusProbe implements ScheduledStatusProbe {
private final int maxFailurePercent;
private int requestCount = 0;
private int downCount = 0;
private Throwable throwable;
private String message;
public PercentageBasedStatusProbe(int maxFailurePercent, Clock clock,
ThreadPoolTaskScheduler threadPoolTaskScheduler, StatusProbeCriticality criticality, String name,
StatusProbeLogger statusProbeLogger) {
this(maxFailurePercent, clock, threadPoolTaskScheduler, Duration.ofMinutes(1), criticality, name,
statusProbeLogger);
}
public PercentageBasedStatusProbe(int maxFailurePercent, Clock clock,
ThreadPoolTaskScheduler threadPoolTaskScheduler, Duration schedulePeriod, StatusProbeCriticality criticality,
String name, StatusProbeLogger statusProbeLogger) {
super(clock, criticality, name, statusProbeLogger);
this.maxFailurePercent = maxFailurePercent;
scheduleTask(threadPoolTaskScheduler, schedulePeriod);
}
@Override
protected synchronized void setStatus(Status status, Throwable throwable, String message) {
if (status == Status.DOWN) {
downCount++;
this.throwable = throwable;
this.message = message;
}
requestCount++;
}
private void reset() {
requestCount = 0;
downCount = 0;
throwable = null;
message = null;
}
public synchronized void runScheduledTask() {
if (requestCount > 0 && (downCount * 100.0 / requestCount) > maxFailurePercent) {
super.setStatus(Status.DOWN, throwable, message);
} else {
super.setStatus(Status.UP, null, null);
}
reset();
}
}
@@ -0,0 +1,18 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Duration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.support.PeriodicTrigger;
public interface ScheduledStatusProbe {
void runScheduledTask();
default void scheduleTask(ThreadPoolTaskScheduler threadPoolTaskScheduler,
Duration schedulePeriod) {
PeriodicTrigger periodicTrigger = new PeriodicTrigger(
Duration.ofSeconds(schedulePeriod.toSeconds()));
threadPoolTaskScheduler.schedule(this::runScheduledTask, periodicTrigger);
}
}
@@ -0,0 +1,62 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Clock;
import java.time.ZonedDateTime;
import lombok.Getter;
import org.springframework.boot.actuate.health.Status;
@Getter
public class StatusProbe {
private final Clock clock;
private Status status = Status.UP;
private Throwable throwable = null;
private String message = null;
private ZonedDateTime lastStatusChange;
private final StatusProbeLogger statusProbeLogger;
private final String name;
public StatusProbe(Clock clock, StatusProbeCriticality criticality, String name,
StatusProbeLogger statusProbeLogger) {
this.clock = clock;
this.name = name;
this.statusProbeLogger = statusProbeLogger;
statusProbeLogger.registerStatusProbe(name, criticality);
}
protected void setStatus(Status status, Throwable throwable, String message) {
if (status != this.status) {
lastStatusChange = ZonedDateTime.now(clock);
statusProbeLogger.logStatusChange(name, message, status, lastStatusChange, throwable);
}
this.status = status;
this.throwable = throwable;
this.message = message;
}
public void up() {
setStatus(Status.UP, null, null);
}
public void up(String message) {
setStatus(Status.UP, null, message);
}
public void down() {
setStatus(Status.DOWN, null, null);
}
public void down(Throwable throwable) {
setStatus(Status.DOWN, throwable, null);
}
public void down(String message) {
setStatus(Status.DOWN, null, message);
}
protected void down(Throwable throwable, String message) {
setStatus(Status.DOWN, throwable, message);
}
}
@@ -0,0 +1,7 @@
package de.twomartens.template.monitoring.statusprobe;
public enum StatusProbeCriticality {
K1, K2, K3
}
@@ -0,0 +1,124 @@
package de.twomartens.template.monitoring.statusprobe;
import static de.twomartens.template.monitoring.statusprobe.StatusProbeCriticality.K1;
import static de.twomartens.template.monitoring.statusprobe.StatusProbeCriticality.K2;
import static de.twomartens.template.monitoring.statusprobe.StatusProbeCriticality.K3;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.Marker;
import org.apache.logging.log4j.MarkerManager;
import org.apache.logging.log4j.message.StringMapMessage;
import org.springframework.boot.actuate.health.Status;
public class StatusProbeLogger {
private static final Marker MARKER = MarkerManager.getMarker("statusprobe");
private static final String LABEL_CRITICALITY = "label.status.criticality";
private static final String LABEL_STATUS = "label.status.status";
private static final String LABEL_REASON = "label.status.reason";
private static final String LABEL_MESSAGE = "label.status.description";
private static final String LABEL_LAST_STATUS_CHANGE = "label.status.last_change";
private final Clock clock;
private final Logger commLog;
private final Map<ProbeIdent, Status> statusProbeToStatus = new HashMap<>();
public StatusProbeLogger(Clock clock) {
this(clock, LogManager.getLogger("statusprobe"));
}
/**
* only for testing purposes
*/
StatusProbeLogger(Clock clock, Logger commLog) {
this.clock = clock;
this.commLog = commLog;
}
public void registerStatusProbe(String name, StatusProbeCriticality criticality) {
statusProbeToStatus.put(new ProbeIdent(name, criticality), Status.UP);
logStatusChange(name, "Startup", Status.UP, ZonedDateTime.now(clock), null);
}
public void logStatusChange(String name, String message, Status status, ZonedDateTime lastStatusChange,
Throwable throwable) {
ProbeIdent probeIdent = getProbeIdent(name);
if (probeIdent == null) {
probeIdent = new ProbeIdent(name, K1);
}
statusProbeToStatus.put(probeIdent, status);
createLog(message, lastStatusChange, throwable);
}
private ProbeIdent getProbeIdent(String name) {
return statusProbeToStatus.keySet().stream().filter(key -> key.name.equals(name)).findFirst().orElse(null);
}
private void createLog(String message, ZonedDateTime lastStatusChange, Throwable throwable) {
Status overallStatus = getOverallStatus();
StatusProbeCriticality criticality = getOverallCriticality();
if (message == null) {
message = "";
}
if (Status.UP.equals(overallStatus)) {
commLog.info(MARKER, new StringMapMessage()
.with(LABEL_CRITICALITY, criticality)
.with(LABEL_STATUS, overallStatus)
.with(LABEL_MESSAGE, message)
.with(LABEL_LAST_STATUS_CHANGE, lastStatusChange));
} else {
commLog.error(MARKER, new StringMapMessage()
.with(LABEL_CRITICALITY, criticality)
.with(LABEL_STATUS, overallStatus)
.with(LABEL_MESSAGE, message)
.with(LABEL_REASON, getReason())
.with(LABEL_LAST_STATUS_CHANGE, lastStatusChange), throwable);
}
}
private StatusProbeCriticality getOverallCriticality() {
List<StatusProbeCriticality> crits = statusProbeToStatus.keySet().stream().map(key -> key.criticality).toList();
return crits.contains(K1) ? K1 : crits.contains(K2) ? K2 : K3;
}
private Status getOverallStatus() {
if (statusProbeToStatus.containsValue(Status.DOWN)) {
return Status.DOWN;
}
return Status.UP;
}
private String getReason() {
List<ProbeIdent> probesDown = statusProbeToStatus.entrySet().stream()
.filter(entry -> Status.DOWN.equals(entry.getValue()))
.map(Map.Entry::getKey)
.toList();
String reasonK1 = getDownStatusProbes(probesDown, K1);
String reasonK2 = getDownStatusProbes(probesDown, K2);
String reasonK3 = getDownStatusProbes(probesDown, K3);
return "%s%s%s".formatted(reasonK1, reasonK2, reasonK3).trim();
}
private String getDownStatusProbes(List<ProbeIdent> probesDown, StatusProbeCriticality criticality) {
List<String> downProbeNames = probesDown.stream().filter(probe -> probe.criticality.equals(criticality))
.map(probe -> probe.name).toList();
if (downProbeNames.size() > 0) {
return criticality + " failed: " + String.join(",", downProbeNames) + "\n";
}
return "";
}
public record ProbeIdent(String name, StatusProbeCriticality criticality) {
}
}
@@ -0,0 +1,64 @@
package de.twomartens.template.monitoring.statusprobe;
import java.time.Clock;
import java.time.Duration;
import java.time.ZonedDateTime;
import org.springframework.boot.actuate.health.Status;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
public class TimeBasedStatusProbe extends StatusProbe implements ScheduledStatusProbe {
private final Clock clock;
private final Duration maxFailureDuration;
private ZonedDateTime lastSuccess;
private Throwable throwable = null;
private String message = null;
public TimeBasedStatusProbe(Duration maxFailureDuration, Clock clock,
ThreadPoolTaskScheduler threadPoolTaskScheduler, StatusProbeCriticality criticality, String name,
StatusProbeLogger statusProbeLogger) {
this(maxFailureDuration, clock, threadPoolTaskScheduler, Duration.ofMinutes(1), criticality, name,
statusProbeLogger);
}
public TimeBasedStatusProbe(Duration maxFailureDuration, Clock clock,
ThreadPoolTaskScheduler threadPoolTaskScheduler, Duration schedulePeriod, StatusProbeCriticality criticality,
String name, StatusProbeLogger statusProbeLogger) {
super(clock, criticality, name, statusProbeLogger);
this.clock = clock;
this.maxFailureDuration = maxFailureDuration;
this.lastSuccess = null;
scheduleTask(threadPoolTaskScheduler, schedulePeriod);
}
@Override
protected synchronized void setStatus(Status status, Throwable throwable, String message) {
if (status == Status.DOWN) {
this.throwable = throwable;
this.message = message;
} else if (status == Status.UP) {
lastSuccess = ZonedDateTime.now(clock);
super.setStatus(status, throwable, message);
}
}
private boolean isOverdue() {
if (lastSuccess == null) {
return false;
}
Duration timeSinceLastSuccess = Duration.between(lastSuccess, ZonedDateTime.now(clock));
return maxFailureDuration.minus(timeSinceLastSuccess).isNegative();
}
public synchronized void runScheduledTask() {
if (isOverdue()) {
super.setStatus(Status.DOWN, throwable, message);
}
}
}
@@ -0,0 +1,34 @@
package de.twomartens.template.property;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.stereotype.Component;
@Getter
@Setter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConstructor
@ConfigurationProperties(prefix = "resttemplate.timeout")
@Component
public class RestTemplateTimeoutProperties {
@DurationUnit(ChronoUnit.MILLIS)
private Duration readTimeoutRestHealthIndicatorInMillis;
@DurationUnit(ChronoUnit.MILLIS)
private Duration connectionRestHealthIndicatorTimeoutInMillis;
@DurationUnit(ChronoUnit.MILLIS)
private Duration readTimeoutRestTemplateInMillis;
@DurationUnit(ChronoUnit.MILLIS)
private Duration connectionRestTemplateTimeoutInMillis;
}
@@ -0,0 +1,21 @@
package de.twomartens.template.property;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.ZoneId;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
@Data
@RefreshScope
@Configuration
@ConfigurationProperties(prefix = "de.twomartens.template")
@Schema(description = "Properties, to configure this Application")
public class ServiceProperties {
private ZoneId defaultTimeZone;
private String greeting;
}
@@ -0,0 +1,28 @@
package de.twomartens.template.property;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
@Data
@RefreshScope
@Configuration
@ConfigurationProperties(prefix = "de.twomartens.template.statusprobe")
@Schema(description = "Properties, to configure this Application")
public class StatusProbeProperties {
@DurationUnit(ChronoUnit.SECONDS)
private Duration scheduleDuration;
@DurationUnit(ChronoUnit.MINUTES)
private Duration maxKafkaFailureDuration;
private int maxBlobFailureCount;
private int maxFailurePercent;
}
@@ -0,0 +1,11 @@
package de.twomartens.template.repository;
import de.twomartens.template.model.db.Greeting;
import java.util.Optional;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface GreetingRepository extends MongoRepository<Greeting, String> {
Optional<Greeting> findByMessageIgnoreCase(String message);
}
@@ -0,0 +1,40 @@
package de.twomartens.template.service;
import de.twomartens.template.model.db.Greeting;
import de.twomartens.template.property.ServiceProperties;
import de.twomartens.template.repository.GreetingRepository;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class GreetingService {
private final MeterRegistry meterRegistry;
private final ServiceProperties serviceProperties;
private final Counter counter;
private final GreetingRepository greetingRepository;
public GreetingService(MeterRegistry meterRegistry, ServiceProperties serviceProperties,
GreetingRepository greetingRepository) {
this.meterRegistry = meterRegistry;
this.serviceProperties = serviceProperties;
this.greetingRepository = greetingRepository;
counter = meterRegistry.counter("template.callCounter");
}
public Greeting createGreeting(String name) {
log.info("Create greeting for '{}'", name);
counter.increment();
meterRegistry.gauge("template.nameLength", name.length());
String greeting = serviceProperties.getGreeting();
return Greeting.builder().message(String.format(greeting, name)).build();
}
public void postGreeting(Greeting greeting) {
greetingRepository.save(greeting);
}
}
@@ -0,0 +1,37 @@
package de.twomartens.template.service;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.logging.log4j.message.StringMapMessage;
import org.springframework.cloud.context.refresh.ContextRefresher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
/**
* automatically reloads application*.yaml. reload can also be triggered manually by doing a post
* request on http://localhost:12001/actuator/refresh
*/
@Log4j2
@Service
@RequiredArgsConstructor
public class PropertyReloadService {
public static final int REFRESH_SECONDS = 60;
public static final String PARAM_MESSAGE = "message";
public static final String PARAM_PROPERTIES = "labels.properties";
private final ContextRefresher contextRefresher;
@Scheduled(fixedDelay = REFRESH_SECONDS, initialDelay = REFRESH_SECONDS, timeUnit = TimeUnit.SECONDS)
public void refresh() {
Set<String> properties = contextRefresher.refresh();
if (!properties.isEmpty()) {
log.info(new StringMapMessage()
.with(PARAM_MESSAGE, "properties changed")
.with(PARAM_PROPERTIES, String.join("\n", properties)));
}
}
}
@@ -0,0 +1,6 @@
#TODO zufaellige Ports eintragen
server.port: 12000
grpc.server.port: 12002
management.server.port: 12001
de.twomartens.template.name: "Template"
@@ -0,0 +1,67 @@
server:
port: 8080
shutdown: graceful
grpc.server.port: 9090
### technical configurations ###
management:
# for security, don't use same port as application
server.port: 8081
health:
livenessState.enabled: true
readinessState.enabled: true
endpoint:
health:
show-details: always
probes.enabled: true
group:
readiness.include: readinessState,diskSpace,grpcChannel,grpc,rest
liveness.include: livenessState
endpoints.web.exposure.include: '*'
# activate percentiles for web and grpc requests
metrics.distribution.percentiles-histogram:
grpc.server.processing.duration: true
http.server.requests: true
spring:
main:
banner-mode: off
profiles:
default: dev
# properties for application
de.twomartens.template:
defaultTimeZone: Europe/Berlin
greeting: "Hello %s"
springdoc:
swagger-ui:
path: '/template/doc/'
api-docs:
path: '/template/api-docs/'
default-produces-media-type: 'application/json'
openapi:
#TODO write description/ changelog
description: |
Open API Documentation for the Template
## Header 2
__YOUR TEXT HERE__
## Changelog
|Version | Change | Author | Date |
| ------ | -------------------------------------- | ------ | ---------- |
| 1.0 | added API Versioning (and changelog) | Jim | 28.05.2023 |
version: 1.0
title: 'Template API'
resttemplate:
timeout:
readTimeoutRestHealthIndicatorInMillis: 5000
connectionRestHealthIndicatorTimeoutInMillis: 5000
readTimeoutRestTemplateInMillis: 5000
connectionRestTemplateTimeoutInMillis: 5000
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<Configuration status="warn" monitorInterval="30">
<Appenders>
<Console name="console" target="SYSTEM_OUT">
<PatternLayout alwaysWriteExceptions="true"
pattern="%5p %d{HH:mm:ss.SSS} (%F:%L) %notEmpty{[%X{REQTYPE}] }%notEmpty{[%marker] }%K{event.end}%m%n%xEx{full}"/>
</Console>
<Console name="interceptor" target="SYSTEM_OUT">
<!-- <EcsLayout servicename="${env:spring_application_name}" includeOrigin="true" includeMarkers="true"/>-->
<PatternLayout alwaysWriteExceptions="true"
pattern="%5p %d{HH:mm:ss.SSS} (%F:%L) %notEmpty{[%K{REQTYPE}] }%notEmpty{[%X{REQTYPE}] }%K{network.direction} %K{http.request.method} %K{http.response.status_code} %K{url.full} (duration=%K{event.duration} request-size=%K{http.request.body.bytes} response-size=%K{http.response.body.bytes} trace-id=%notEmpty{%K{trace.id}}%notEmpty{%X{trace.id}})%n%xEx{full}"/>
</Console>
</Appenders>
<Loggers>
<Root level="WARN">
<AppenderRef ref="console"/>
</Root>
<Logger additivity="true" level="INFO" name="statusprobe"/>
</Loggers>
</Configuration>
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<th:block th:insert="~{errorIncludes :: head}"/>
<body>
<th:block th:insert="~{errorIncludes :: mainText}"/>
<th:block th:insert="~{errorIncludes :: footer}"/>
</body>
</html>
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<th:block th:insert="~{errorIncludes :: head}" />
<body>
<th:block th:insert="~{errorIncludes :: mainText}" />
<p>The requested URL <code th:text="${path}"></code> was not found on this server.</p>
<th:block th:insert="~{errorIncludes :: footer}" />
</body>
</html>
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head">
<title>Error <th:block th:text="${status}"/> (<th:block th:text="${error}"/>)</title>
<style>
@media (prefers-color-scheme: dark) {
html{background:#000000;color:#ffffff;font:15px/22px arial,sans-serif}
}
@media (prefers-color-scheme: light) {
html{background:#ffffff;color:#000000;font:15px/22px arial,sans-serif}
}
body{margin:50px}
h1,footer{margin:20px 0}
p{margin:0}
code{font-family: monospace,monospace}
.foot{color:#777777;font-size:80%}
</style>
</head>
<body>
<th:block th:fragment="mainText">
<h1>Template</h1>
<p>
<b th:text="${status}"></b> <th:block th:text="${error}" />
</p>
</th:block>
<footer class="foot" th:fragment="footer"><th:block th:text="${footerString}" /></footer>
</body>
</html>
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head">
<title><th:block th:text="${version}"/></title>
<style>
@media (prefers-color-scheme: dark) {
html{background:#000000;color:#ffffff;font:15px/22px arial,sans-serif}
}
@media (prefers-color-scheme: light) {
html{background:#ffffff;color:#000000;font:15px/22px arial,sans-serif}
}
body{margin:50px}
h1,footer{margin:20px 0}
p{margin:0}
code{font-family: monospace,monospace}
.foot{color:#777777;font-size:80%}
</style>
</head>
<body style="font-family:sans-serif; line-height: 1.4em;">
<h2>
<th:block th:text="${version}"/>
</h2>
<p>
Hostname:
<th:block th:text="${hostname}"/>
</p>
<p>
<th:block th:each="item : ${manifest}">
<th:block th:text="${item}"/>
<br/>
</th:block>
</p>
<footer class="foot" th:fragment="footer">
<th:block th:text="${footerString}"/>
</footer>
</body>
</html>