Code development platform for open source projects from the European Union institutions :large_blue_circle: EU Login authentication by SMS has been phased out. To see alternatives please check here

Skip to content
Snippets Groups Projects
Commit 7dc4f67e authored by Tomasz Kalisz's avatar Tomasz Kalisz
Browse files

Merge branch 'develop' into 'main'

Release 0.0.2

See merge request !17
parents 2d2adad9 f475d379
Branches
Tags
1 merge request!17Release 0.0.2
Pipeline #281381 failed
Showing
with 402 additions and 105 deletions
......@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.2] - 2025-03-10
### Changed
- (SIMPL-10405) webConfig refactored
- (SIMPL-10551) interfaces refactored; notification channel introduced; default "email" channel added
- (SIMPL-10007) Sonar fixes
### Added
- (SIMPL-10454) asyncAPI created
- (SIMPL-10731) retry mechanism
### Removed
- (SPGRLOG-1630) REST API removed
## [0.0.1] - 2025-01-15
### Changed
......
......@@ -32,7 +32,7 @@ spec:
{{- tpl $template . | nindent 10 }}
spec:
automountServiceAccountToken: true
serviceAccountName: simpl-contract-service-account
serviceAccountName: {{ .Chart.Name }}-service-account
containers:
- name: {{ .Release.Name }}
image: "{{ .Values.deployment.image.artifact }}:{{ .Values.deployment.image.tag }}"
......@@ -52,6 +52,8 @@ spec:
value: {{ .Values.deployment.kafka.bootstrapServer }}
- name: KAFKA_CLIENT_USER
value: {{ .Values.deployment.kafka.clientUser }}
- name: KAFKA_RETRY_TOPIC_BACKOFF_DELAY
value: {{ .Values.deployment.kafka.retryTopicBackoffDelay}}
- name: KAFKA_SECURITY_PROTOCOL
value: {{ .Values.deployment.kafka.securityProtocol }}
- name: KAFKA_SASL_MECHANISM
......
......@@ -8,6 +8,7 @@ deployment:
bootstrapServer: http://kafka.be-common.svc.cluster.local:9092
clientUser: user1
securityProtocol: SASL_PLAINTEXT
retryTopicBackoffDelay: 10s
sasl:
mechanism: PLAIN
mail:
......
asyncapi: '3.0.0'
info:
title: Notification Service API
version: '1.0.0'
description: API documentation for a notification service using Kafka.
defaultContentType: application/json
servers:
production:
host: 'kafka://localhost:9094'
protocol: kafka-secure
description: Kafka server
channels:
notifications:
address: "notifications"
messages:
EmailNotification:
$ref: "#/components/messages/EmailNotification"
operations:
SendNotification:
action: send
summary: Sending notification message to Kafka topic 'notifications'
channel:
$ref: '#/channels/notifications'
components:
messages:
EmailNotification:
name: EmailNotification
title: Sending email notification
payload:
type: object
properties:
channel:
type: string
enum:
- email
description: Type of notification channel.
message:
type: string
description: Body of the message.
to:
type: string
description: Email address of the recipient.
cc:
type: array
items:
type: string
description: List of email addresses in CC.
subject:
type: string
description: Subject of the message.
$ref: '#/components/messages/EmailNotification'
PROJECT_VERSION_NUMBER=0.0.1
PROJECT_VERSION_NUMBER=0.0.2
......@@ -103,6 +103,12 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>nl.jqno.equalsverifier</groupId>
<artifactId>equalsverifier</artifactId>
<version>3.19.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
......
package eu.europa.ec.simpl.notification_service.controller;
import eu.europa.ec.simpl.notification_service.model.EmailDTO;
import eu.europa.ec.simpl.notification_service.service.MailService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
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.RestController;
@Slf4j
@RestController
@RequiredArgsConstructor
@Validated
@RequestMapping("/contract/v1/notifications")
public class NotificationController {
private final MailService mailService;
@PostMapping("/email")
public ResponseEntity<String> sendEmail(
@Valid
@RequestBody
EmailDTO email) {
if (mailService.send(email)) {
return ResponseEntity.ok().body("OK");
}
return ResponseEntity.internalServerError().body("Email sending failure");
}
}
......@@ -2,14 +2,17 @@ package eu.europa.ec.simpl.notification_service.controller;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class WebBeanConfig {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
return web -> web.ignoring().requestMatchers("/actuator/health");
http.securityMatcher("/actuator/**").authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());
return http.build();
}
}
......@@ -2,64 +2,78 @@ package eu.europa.ec.simpl.notification_service.kafka;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import eu.europa.ec.simpl.notification_service.model.EmailDTO;
import eu.europa.ec.simpl.notification_service.service.MailService;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import eu.europa.ec.simpl.notification_service.model.EmailNotification;
import eu.europa.ec.simpl.notification_service.model.AbstractNotification;
import eu.europa.ec.simpl.notification_service.service.NotificationException;
import eu.europa.ec.simpl.notification_service.service.Sender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;
import java.util.stream.Collectors;
import java.time.Duration;
@Component
@Slf4j
@RequiredArgsConstructor
public class Consumer {
static Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Value("${mail.default.receiver}")
private String defaultReceiver;
private final MailService mailService;
@Value("${spring.kafka.retry.topic.backoff.delay}")
private Duration backoffDelay;
private final Sender sender;
private final ObjectMapper objectMapper;
@KafkaListener(topics = "${spring.kafka.topics.notifications}", groupId = "${spring.kafka.consumer.group-id}")
public void receive(String message) {
public void receive(
@Payload
String message, Acknowledgment ack) {
log.info("Received Kafka message: " + message);
var messageToSend = mapAndValidateMessage(message);
mailService.send(messageToSend);
}
log.info("Received message: {}", message);
var messageToSend = getMappedMessage(message);
var validationResult = messageToSend.validate();
private EmailDTO mapAndValidateMessage(String message) {
if (!validationResult.isEmpty()) {
messageToSend = prepareDefaultErrorNotification(validationResult, message);
}
try {
var mapped = objectMapper.readValue(message, EmailDTO.class);
var validated = validator.validate(mapped);
if (validated.isEmpty()) {
return mapped;
} else {
var violations =
validated.stream().map(ConstraintViolation::getMessageTemplate).collect(Collectors.joining("\n"));
return prepareDefaultEmail("validation error:\n" + violations, message);
messageToSend.send(sender);
log.info("Message sent successfully");
ack.acknowledge();
} catch (NotificationException ex) {
log.error("Could not send message: {}", message);
log.error("Error: {}", ex.getMessage());
ack.nack(backoffDelay);
}
}
private AbstractNotification getMappedMessage(String message) {
try {
return objectMapper.readValue(message, AbstractNotification.class);
} catch (JsonProcessingException e) {
log.error(String.valueOf(e));
log.error("Failed to deserialize JSON message. Preparing custom email");
return prepareDefaultEmail("message deserialization error", message);
log.error("Could not parse message: {}", message);
return prepareDefaultErrorNotification("Parse message error", message);
}
}
private EmailDTO prepareDefaultEmail(String cause, String message) {
private EmailNotification prepareDefaultErrorNotification(String cause, String message) {
return EmailDTO.builder().to(defaultReceiver).body(
var notification =
EmailNotification.builder().to(defaultReceiver).subject("Default error notification from SIMPL").build();
notification.setMessage(
"This notification is generated automatically because of " + cause + "\nORIGINAL MESSAGE BELOW:\n"
+ message).build();
+ message);
return notification;
}
}
......@@ -12,6 +12,7 @@ import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.listener.ContainerProperties;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.util.backoff.FixedBackOff;
......@@ -50,6 +51,7 @@ public class ConsumerConfig {
configProps.put(org.apache.kafka.clients.consumer.ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
StringDeserializer.class);
configProps.put(org.apache.kafka.clients.consumer.ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
configProps.put(org.apache.kafka.clients.consumer.ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
if (isDefaultProfile()) {
configProps.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SASL_PLAINTEXT");
......@@ -73,6 +75,8 @@ public class ConsumerConfig {
var factory = new ConcurrentKafkaListenerContainerFactory<String, String>();
factory.setConsumerFactory(consumerFactory());
factory.setCommonErrorHandler(new DefaultErrorHandler(new FixedBackOff(FIXED_BACK_OFF, 1)));
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
return factory;
}
}
package eu.europa.ec.simpl.notification_service.model;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import eu.europa.ec.simpl.notification_service.service.Sender;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import java.util.stream.Collectors;
@Slf4j
@Data
@AllArgsConstructor
@RequiredArgsConstructor
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "channel", visible = true, defaultImpl = EmailNotification.class)
@JsonSubTypes({ ///
@JsonSubTypes.Type(value = EmailNotification.class, name = Channel.Constants.EMAIL_VALUE), ///
@JsonSubTypes.Type(value = SMSNotification.class, name = Channel.Constants.SMS_VALUE)})
public abstract class AbstractNotification {
private static final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
private String channel;
private String message;
public abstract void send(Sender visitor);
public String validate() {
var validated = validator.validate(this);
if (validated.isEmpty()) {
return Strings.EMPTY;
} else {
var violations =
validated.stream().map(ConstraintViolation::getMessageTemplate).collect(Collectors.joining("\n"));
log.error("Message validation failed: {}", violations);
return "Validation error:\n" + violations;
}
}
}
package eu.europa.ec.simpl.notification_service.model;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Channel {
EMAIL(Constants.EMAIL_VALUE), SMS(Constants.SMS_VALUE);
@JsonValue
private final String value;
public static final class Constants {
public static final String EMAIL_VALUE = "email";
public static final String SMS_VALUE = "sms";
private Constants() {
}
}
}
package eu.europa.ec.simpl.notification_service.model;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import eu.europa.ec.simpl.notification_service.service.Sender;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@EqualsAndHashCode(callSuper = true)
@Data
@AllArgsConstructor
@Builder
public class EmailDTO {
@Slf4j
public class EmailNotification extends AbstractNotification {
private static final String EMAIL_REGEXP =
"^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+){0,100}@(?:[a-zA-Z0-9-]+\\.){1,100}[a-zA-Z]{2,7}$";
"^[\\p{IsAlphabetic}0-9_+&*-]+(?:\\.[\\p{IsAlphabetic}0-9_+&*-]+){0,100}@(?:[\\p{IsAlphabetic}0-9-]+\\.){1,100}[\\p{IsAlphabetic}]{2,7}$";
@NotBlank
@Size(max = 100, message = "Email address FROM is too long")
@Size(max = 100, message = "Email address is too long")
@Email(message = "Email should be valid", regexp = EMAIL_REGEXP)
private String to;
......@@ -27,7 +37,28 @@ public class EmailDTO {
@Size(max = 100, message = "Email SUBJECT is too long")
private String subject;
private String body;
@JsonCreator
public EmailNotification(
@JsonProperty("message")
String message,
@JsonProperty("to")
String to,
@JsonProperty("cc")
List<String> cc,
@JsonProperty("subject")
String subject) {
super(Channel.EMAIL.getValue(), message);
this.to = to;
this.subject = subject;
this.cc = Optional.ofNullable(cc).map(List::copyOf).orElseGet(List::of);
}
@Override
public void send(Sender visitor) {
visitor.send(this);
}
public String[] getCc() {
......
package eu.europa.ec.simpl.notification_service.model;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import eu.europa.ec.simpl.notification_service.service.Sender;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
@EqualsAndHashCode(callSuper = true)
@Data
public class SMSNotification extends AbstractNotification {
private static final String PHONE_NUMBER_REGEX =
"(?U)^[+|0]?\\d{1,20}?[-|\\s]{0,2}?\\d{1,20}[-|\\s]{0,2}?\\d{1,20}[-|\\s]{0,2}?\\d{1,20}$";
@NotBlank
@Size(max = 20, message = "Phone number is too long")
@Pattern(regexp = PHONE_NUMBER_REGEX, message = "Wrong phone number format")
private String recipient;
@JsonCreator
public SMSNotification(
@JsonProperty("message")
String message,
@JsonProperty("recipient")
String recipient) {
super(Channel.SMS.getValue(), message);
this.recipient = recipient;
}
@Override
public void send(Sender visitor) {
visitor.send(this);
}
}
package eu.europa.ec.simpl.notification_service.service;
import eu.europa.ec.simpl.notification_service.model.EmailDTO;
import eu.europa.ec.simpl.notification_service.model.EmailNotification;
import org.springframework.messaging.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
......@@ -11,8 +11,6 @@ import org.springframework.stereotype.Service;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import java.util.Objects;
@Slf4j
@Service
@RequiredArgsConstructor
......@@ -30,53 +28,36 @@ public class MailService {
private final JavaMailSenderImpl javaMailSender;
public boolean send(EmailDTO emailDTO) {
public void send(EmailNotification emailNotification) {
var result = false;
var mimeMessage = prepareMimeMessage(emailDTO);
if (Objects.nonNull(mimeMessage)) {
result = sendMail(mimeMessage);
try {
var mimeMessage = prepareMimeMessage(emailNotification);
javaMailSender.send(mimeMessage);
log.info(EMAIL_SEND_SUCCESS);
} catch (MessagingException | jakarta.mail.MessagingException ex) {
log.error(EMAIL_FAILED, ex);
throw new NotificationException(EMAIL_FAILED, ex);
}
return result;
}
private MimeMessage prepareMimeMessage(EmailDTO email) {
private MimeMessage prepareMimeMessage(EmailNotification email)
throws jakarta.mail.MessagingException, MessagingException {
var mimeMessage = javaMailSender.createMimeMessage();
try {
var mimeMessageHelper = new MimeMessageHelper(mimeMessage, true);
mimeMessageHelper.setFrom(sender);
mimeMessageHelper.setTo(email.getTo());
mimeMessageHelper.setCc(email.getCc());
mimeMessageHelper.setSubject(handleSubject(email));
mimeMessageHelper.setText(email.getBody(), false);
} catch (MessagingException | jakarta.mail.MessagingException ex) {
log.error(EMAIL_FAILED);
log.error(String.valueOf(ex));
return null;
}
mimeMessageHelper.setText(email.getMessage(), false);
return mimeMessage;
}
private String handleSubject(EmailDTO email) {
private String handleSubject(EmailNotification email) {
if (Strings.isNotBlank(email.getSubject())) {
return email.getSubject();
}
return defaultEmailSubject;
}
private boolean sendMail(MimeMessage mimeMessage) {
try {
log.info("Sending notification email: {}", mimeMessage.getSubject());
javaMailSender.send(mimeMessage);
log.info(EMAIL_SEND_SUCCESS);
return true;
} catch (MessagingException | jakarta.mail.MessagingException ex) {
log.error(EMAIL_FAILED);
log.error(String.valueOf(ex));
return false;
}
}
}
package eu.europa.ec.simpl.notification_service.service;
public class NotificationException extends RuntimeException {
public NotificationException(String message, Throwable cause) {
super(message, cause);
}
}
package eu.europa.ec.simpl.notification_service.service;
import eu.europa.ec.simpl.notification_service.model.SMSNotification;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class SMSService {
public void send(SMSNotification sms) {
log.info("Sending SMS {}", sms);
//TO BE IMPLEMENTED
}
}
package eu.europa.ec.simpl.notification_service.service;
import eu.europa.ec.simpl.notification_service.model.EmailNotification;
import eu.europa.ec.simpl.notification_service.model.SMSNotification;
public interface Sender {
void send(EmailNotification emailNotification);
void send(SMSNotification sms);
}
package eu.europa.ec.simpl.notification_service.service;
import eu.europa.ec.simpl.notification_service.model.EmailNotification;
import eu.europa.ec.simpl.notification_service.model.SMSNotification;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class SenderImpl implements Sender {
private final MailService mailService;
private final SMSService smsService;
@Override
public void send(EmailNotification emailNotification) {
log.info("Sending email notification: {}", emailNotification);
mailService.send(emailNotification);
}
@Override
public void send(SMSNotification sms) {
log.info("Sending SMS: {}", sms);
smsService.send(sms);
}
}
......@@ -19,6 +19,7 @@ spring.kafka.properties.security.protocol=${KAFKA_SECURITY_PROTOCOL}
spring.kafka.properties.sasl.mechanism=${KAFKA_SASL_MECHANISM}
spring.kafka.properties.sasl.jaas.config=${KAFKA_SASL_JAAS_CONFIG}
spring.kafka.jaas.options.password=${KAFKA_CLIENT_PASSWORDS}
spring.kafka.retry.topic.backoff.delay=${KAFKA_RETRY_TOPIC_BACKOFF_DELAY}
###################
spring.api-key=${API_KEY}
spring.profile=default
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment