From a6f46a018f1babce88d9a2e928de3e6eb80a7974 Mon Sep 17 00:00:00 2001 From: oleksandr-jr Date: Fri, 27 Jun 2025 19:55:16 +0200 Subject: [PATCH 1/3] Add Redis caching and improve contact data management - Integrated Redis caching for `ContactService` methods to improve performance. - Added `RedisConfig` and `RedisHealthCheck` components for configuration and health checks. - Updated `application.yaml` with Redis configuration settings. - Introduced `ContactCacheMapper` and `ContactCacheDto` for efficient data transfer. - Modified repository methods to support partial name searches. - Adjusted existing classes and tests to use `ContactService` for data operations. --- compose.yaml | 11 ++- pom.xml | 9 ++- .../ContactManagerSpringBootApplication.java | 2 + .../gnew/contactm/DTOs/ContactCacheDto.java | 25 +++++++ .../contactm/component/RedisHealthCheck.java | 26 +++++++ .../gnew/contactm/config/RedisConfig.java | 68 +++++++++++++++++++ .../controller/rest/ContactControllerApi.java | 16 ++--- .../controller/web/ContactController.java | 9 ++- .../controller/web/HomeController.java | 14 ++-- .../gnew/contactm/entity/Contact.java | 8 ++- .../gnew/contactm/entity/ContactBook.java | 2 + .../javarush/gnew/contactm/entity/Email.java | 2 + .../javarush/gnew/contactm/entity/Phone.java | 2 + .../gnew/contactm/entity/SocialNetwork.java | 2 + .../contactm/mapper/ContactCacheMapper.java | 48 +++++++++++++ .../repository/ContactRepository.java | 5 +- .../contactm/services/ContactService.java | 54 +++++++++++++-- src/main/resources/application.yaml | 14 +++- .../rest/ContactControllerApiTest.java | 21 +++++- 19 files changed, 308 insertions(+), 30 deletions(-) create mode 100644 src/main/java/ua/com/javarush/gnew/contactm/DTOs/ContactCacheDto.java create mode 100644 src/main/java/ua/com/javarush/gnew/contactm/component/RedisHealthCheck.java create mode 100644 src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java create mode 100644 src/main/java/ua/com/javarush/gnew/contactm/mapper/ContactCacheMapper.java diff --git a/compose.yaml b/compose.yaml index 972b861..88a960c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,5 +10,14 @@ services: volumes: - postgres_data:/var/lib/postgresql/data # Persists database data + redis: + image: redis:7-alpine + ports: + - '6379:6379' + volumes: + - redis_data:/data + command: redis-server --appendonly yes + volumes: - postgres_data: \ No newline at end of file + postgres_data: + redis_data: \ No newline at end of file diff --git a/pom.xml b/pom.xml index 6f64477..2c1d120 100644 --- a/pom.xml +++ b/pom.xml @@ -155,7 +155,14 @@ cloudinary-http5 2.3.0 - + + org.springframework.boot + spring-boot-starter-data-redis + + + com.fasterxml.jackson.datatype + jackson-datatype-hibernate6 + diff --git a/src/main/java/ua/com/javarush/gnew/contactm/ContactManagerSpringBootApplication.java b/src/main/java/ua/com/javarush/gnew/contactm/ContactManagerSpringBootApplication.java index 85d1e7e..8920d0b 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/ContactManagerSpringBootApplication.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/ContactManagerSpringBootApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication +@EnableCaching public class ContactManagerSpringBootApplication { public static void main(String[] args) { diff --git a/src/main/java/ua/com/javarush/gnew/contactm/DTOs/ContactCacheDto.java b/src/main/java/ua/com/javarush/gnew/contactm/DTOs/ContactCacheDto.java new file mode 100644 index 0000000..7d75ac0 --- /dev/null +++ b/src/main/java/ua/com/javarush/gnew/contactm/DTOs/ContactCacheDto.java @@ -0,0 +1,25 @@ +package ua.com.javarush.gnew.contactm.DTOs; + +import java.util.Date; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ContactCacheDto { + private long id; + private String name; + private String lastName; + private String imageUrl; + private Date createDate; + private Date modifyDate; + private List emailAddresses; + private List phoneNumbers; + private List socialNetworks; + private String contactBookName; +} diff --git a/src/main/java/ua/com/javarush/gnew/contactm/component/RedisHealthCheck.java b/src/main/java/ua/com/javarush/gnew/contactm/component/RedisHealthCheck.java new file mode 100644 index 0000000..a90de18 --- /dev/null +++ b/src/main/java/ua/com/javarush/gnew/contactm/component/RedisHealthCheck.java @@ -0,0 +1,26 @@ +package ua.com.javarush.gnew.contactm.component; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisHealthCheck { + + private final RedisTemplate redisTemplate; + + @PostConstruct + public void checkRedisConnection() { + try { + String pong = redisTemplate.getConnectionFactory().getConnection().ping(); + log.info("Redis connection successful. Response: {}", pong); + } catch (Exception e) { + log.warn("Redis connection failed: {}", e.getMessage()); + log.warn("Application will continue without Redis caching"); + } + } +} diff --git a/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java b/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java new file mode 100644 index 0000000..1106c0a --- /dev/null +++ b/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java @@ -0,0 +1,68 @@ +package ua.com.javarush.gnew.contactm.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module; +import java.time.Duration; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Getter +@Configuration +@Slf4j +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate tpl = new RedisTemplate<>(); + tpl.setConnectionFactory(connectionFactory); + + // key serializer + tpl.setKeySerializer(new StringRedisSerializer()); + tpl.setHashKeySerializer(new StringRedisSerializer()); + + // JSON value serializer with Hibernate support + ObjectMapper objectMapper = new ObjectMapper(); + Hibernate6Module hibernate6Module = new Hibernate6Module(); + hibernate6Module.configure(Hibernate6Module.Feature.FORCE_LAZY_LOADING, false); + hibernate6Module.configure( + Hibernate6Module.Feature.SERIALIZE_IDENTIFIER_FOR_LAZY_NOT_LOADED_OBJECTS, true); + objectMapper.registerModule(hibernate6Module); + + GenericJackson2JsonRedisSerializer jsonSer = + new GenericJackson2JsonRedisSerializer(objectMapper); + tpl.setValueSerializer(jsonSer); + tpl.setHashValueSerializer(jsonSer); + + tpl.afterPropertiesSet(); + log.info("Redis template initialized"); + return tpl; + } + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory cf) { + ObjectMapper objectMapper = new ObjectMapper(); + Hibernate6Module hibernate6Module = new Hibernate6Module(); + hibernate6Module.configure(Hibernate6Module.Feature.FORCE_LAZY_LOADING, false); + hibernate6Module.configure( + Hibernate6Module.Feature.SERIALIZE_IDENTIFIER_FOR_LAZY_NOT_LOADED_OBJECTS, true); + objectMapper.registerModule(hibernate6Module); + + RedisCacheConfiguration defaultCfg = + RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(60)) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer(objectMapper))); + + return RedisCacheManager.builder(cf).cacheDefaults(defaultCfg).build(); + } +} diff --git a/src/main/java/ua/com/javarush/gnew/contactm/controller/rest/ContactControllerApi.java b/src/main/java/ua/com/javarush/gnew/contactm/controller/rest/ContactControllerApi.java index ad96d67..eb830b2 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/controller/rest/ContactControllerApi.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/controller/rest/ContactControllerApi.java @@ -10,14 +10,14 @@ import ua.com.javarush.gnew.contactm.DTOs.ContactDTO; import ua.com.javarush.gnew.contactm.entity.Contact; import ua.com.javarush.gnew.contactm.mapper.ContactMapper; -import ua.com.javarush.gnew.contactm.repository.ContactRepository; +import ua.com.javarush.gnew.contactm.services.ContactService; @Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/contact") public class ContactControllerApi { - private final ContactRepository contactRepository; + private final ContactService contactService; private final ContactMapper contactMapper; @PreAuthorize("hasRole('USER')") @@ -35,7 +35,7 @@ public String adminEndpoint() { @GetMapping public ResponseEntity getContact(@RequestParam("id") Long id) { log.debug("getContact: id={}", id); - return contactRepository + return contactService .findById(id) .map(contact -> new ResponseEntity<>(contactMapper.toDto(contact), HttpStatus.OK)) .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND)); @@ -44,14 +44,14 @@ public ResponseEntity getContact(@RequestParam("id") Long id) { @PostMapping public ResponseEntity save(@RequestBody ContactDTO contactDTO) { Contact contact = contactMapper.toEntity(contactDTO); - Contact saved = contactRepository.save(contact); + Contact saved = contactService.save(contact); return new ResponseEntity<>(contactMapper.toDto(saved), HttpStatus.CREATED); } @PutMapping public ResponseEntity update( @RequestParam("id") Long id, @RequestBody ContactDTO contactDTO) { - Optional existingOpt = contactRepository.findById(id); + Optional existingOpt = contactService.findById(id); if (existingOpt.isEmpty()) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } @@ -59,14 +59,14 @@ public ResponseEntity update( Contact contactToUpdate = contactMapper.toEntity(contactDTO); contactToUpdate.setId(id); - Contact saved = contactRepository.save(contactToUpdate); + Contact saved = contactService.save(contactToUpdate); return new ResponseEntity<>(contactMapper.toDto(saved), HttpStatus.OK); } @DeleteMapping public ResponseEntity delete(@RequestParam("id") Long id) { - if (contactRepository.existsById(id)) { - contactRepository.deleteById(id); + if (contactService.existsById(id)) { + contactService.deleteById(id); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } else { return new ResponseEntity<>(HttpStatus.NOT_FOUND); diff --git a/src/main/java/ua/com/javarush/gnew/contactm/controller/web/ContactController.java b/src/main/java/ua/com/javarush/gnew/contactm/controller/web/ContactController.java index fd85268..d881541 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/controller/web/ContactController.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/controller/web/ContactController.java @@ -1,6 +1,7 @@ package ua.com.javarush.gnew.contactm.controller.web; import java.io.IOException; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -8,6 +9,7 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import ua.com.javarush.gnew.contactm.DTOs.ContactDTO; +import ua.com.javarush.gnew.contactm.entity.Contact; import ua.com.javarush.gnew.contactm.mapper.ContactMapper; import ua.com.javarush.gnew.contactm.services.CloudinaryService; import ua.com.javarush.gnew.contactm.services.ContactService; @@ -23,7 +25,12 @@ public class ContactController { @GetMapping("/edit/{id}") public String edit(@PathVariable Long id, Model model) { - ContactDTO dto = contactMapper.toDto(contactService.findById(id)); + Optional byId = contactService.findById(id); + + // TODO: 404 if not found + Contact contact = byId.get(); + + ContactDTO dto = contactMapper.toDto(contact); model.addAttribute("contact", dto); return "contact/edit"; } diff --git a/src/main/java/ua/com/javarush/gnew/contactm/controller/web/HomeController.java b/src/main/java/ua/com/javarush/gnew/contactm/controller/web/HomeController.java index d639330..0d4cd20 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/controller/web/HomeController.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/controller/web/HomeController.java @@ -1,24 +1,24 @@ package ua.com.javarush.gnew.contactm.controller.web; import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import ua.com.javarush.gnew.contactm.entity.Contact; -import ua.com.javarush.gnew.contactm.repository.ContactRepository; +import ua.com.javarush.gnew.contactm.services.ContactService; @Controller +@RequiredArgsConstructor +@Slf4j public class HomeController { - private final ContactRepository contactRepository; - - public HomeController(ContactRepository contactRepository) { - this.contactRepository = contactRepository; - } + private final ContactService contactService; @GetMapping public String home(Model model) { - List all = contactRepository.findAll(); + List all = contactService.findAll(); model.addAttribute("tableName", "All contacts"); model.addAttribute("contacts", all); return "home"; diff --git a/src/main/java/ua/com/javarush/gnew/contactm/entity/Contact.java b/src/main/java/ua/com/javarush/gnew/contactm/entity/Contact.java index 7b8dc67..26a3934 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/entity/Contact.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/entity/Contact.java @@ -1,5 +1,7 @@ package ua.com.javarush.gnew.contactm.entity; +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonManagedReference; import com.google.gson.annotations.Expose; import jakarta.persistence.*; import java.util.ArrayList; @@ -26,6 +28,7 @@ public class Contact { @ManyToOne @JoinColumn(name = "contact_book_id") + @JsonBackReference private ContactBook contactBook; @Column(name = "name") @@ -35,18 +38,21 @@ public class Contact { // Consider switching to LAZY loading if appropriate. @OneToMany(mappedBy = "contact", fetch = FetchType.EAGER, cascade = CascadeType.ALL) @Expose + @JsonManagedReference private List emails = new ArrayList<>(); @Column(name = "last_name") @Expose private String lastName; - @OneToMany(mappedBy = "contact", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @OneToMany(mappedBy = "contact", fetch = FetchType.EAGER, cascade = CascadeType.ALL) @Expose + @JsonManagedReference private List phones = new ArrayList<>(); @OneToMany(mappedBy = "contact", fetch = FetchType.EAGER, cascade = CascadeType.ALL) @Expose + @JsonManagedReference private List networks = new ArrayList<>(); @Expose private String imageUrl; diff --git a/src/main/java/ua/com/javarush/gnew/contactm/entity/ContactBook.java b/src/main/java/ua/com/javarush/gnew/contactm/entity/ContactBook.java index b6aaea4..6bfaf9b 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/entity/ContactBook.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/entity/ContactBook.java @@ -1,5 +1,6 @@ package ua.com.javarush.gnew.contactm.entity; +import com.fasterxml.jackson.annotation.JsonManagedReference; import com.google.gson.annotations.Expose; import jakarta.persistence.*; import java.util.Date; @@ -33,6 +34,7 @@ public class ContactBook { @OneToMany(mappedBy = "contactBook", fetch = FetchType.EAGER, cascade = CascadeType.ALL) @Expose + @JsonManagedReference private List contacts; @CreationTimestamp diff --git a/src/main/java/ua/com/javarush/gnew/contactm/entity/Email.java b/src/main/java/ua/com/javarush/gnew/contactm/entity/Email.java index 86926a6..52e9a94 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/entity/Email.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/entity/Email.java @@ -1,5 +1,6 @@ package ua.com.javarush.gnew.contactm.entity; +import com.fasterxml.jackson.annotation.JsonBackReference; import com.google.gson.annotations.Expose; import jakarta.persistence.*; import lombok.Getter; @@ -26,6 +27,7 @@ public class Email { @ManyToOne(fetch = FetchType.EAGER, optional = true) @JoinColumn(name = "contact_id", nullable = true) + @JsonBackReference private Contact contact; @Override diff --git a/src/main/java/ua/com/javarush/gnew/contactm/entity/Phone.java b/src/main/java/ua/com/javarush/gnew/contactm/entity/Phone.java index 320138d..77ff492 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/entity/Phone.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/entity/Phone.java @@ -1,5 +1,6 @@ package ua.com.javarush.gnew.contactm.entity; +import com.fasterxml.jackson.annotation.JsonBackReference; import com.google.gson.annotations.Expose; import jakarta.persistence.*; import lombok.Getter; @@ -27,6 +28,7 @@ public class Phone { @ManyToOne(fetch = FetchType.LAZY, optional = true) @JoinColumn(name = "contact_id", nullable = true) + @JsonBackReference private Contact contact; @Override diff --git a/src/main/java/ua/com/javarush/gnew/contactm/entity/SocialNetwork.java b/src/main/java/ua/com/javarush/gnew/contactm/entity/SocialNetwork.java index f7f7558..ef17375 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/entity/SocialNetwork.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/entity/SocialNetwork.java @@ -1,5 +1,6 @@ package ua.com.javarush.gnew.contactm.entity; +import com.fasterxml.jackson.annotation.JsonBackReference; import com.google.gson.annotations.Expose; import jakarta.persistence.*; import lombok.Getter; @@ -24,6 +25,7 @@ public class SocialNetwork { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "contact_id", nullable = true) + @JsonBackReference private Contact contact; @Override diff --git a/src/main/java/ua/com/javarush/gnew/contactm/mapper/ContactCacheMapper.java b/src/main/java/ua/com/javarush/gnew/contactm/mapper/ContactCacheMapper.java new file mode 100644 index 0000000..83d87f5 --- /dev/null +++ b/src/main/java/ua/com/javarush/gnew/contactm/mapper/ContactCacheMapper.java @@ -0,0 +1,48 @@ +package ua.com.javarush.gnew.contactm.mapper; + +import java.util.List; +import java.util.stream.Collectors; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; +import ua.com.javarush.gnew.contactm.DTOs.ContactCacheDto; +import ua.com.javarush.gnew.contactm.entity.Contact; +import ua.com.javarush.gnew.contactm.entity.Email; +import ua.com.javarush.gnew.contactm.entity.Phone; +import ua.com.javarush.gnew.contactm.entity.SocialNetwork; + +@Mapper(componentModel = "spring") +public interface ContactCacheMapper { + + @Mapping(target = "emailAddresses", source = "emails", qualifiedByName = "emailsToStrings") + @Mapping(target = "phoneNumbers", source = "phones", qualifiedByName = "phonesToStrings") + @Mapping(target = "socialNetworks", source = "networks", qualifiedByName = "networksToStrings") + @Mapping(target = "contactBookName", source = "contactBook.name") + ContactCacheDto toDto(Contact contact); + + List toDtoList(List contacts); + + @Named("emailsToStrings") + default List emailsToStrings(List emails) { + if (emails == null) { + return List.of(); + } + return emails.stream().map(Email::getEmail).collect(Collectors.toList()); + } + + @Named("phonesToStrings") + default List phonesToStrings(List phones) { + if (phones == null) { + return List.of(); + } + return phones.stream().map(Phone::getPhone).collect(Collectors.toList()); + } + + @Named("networksToStrings") + default List networksToStrings(List networks) { + if (networks == null) { + return List.of(); + } + return networks.stream().map(SocialNetwork::getAccount).collect(Collectors.toList()); + } +} diff --git a/src/main/java/ua/com/javarush/gnew/contactm/repository/ContactRepository.java b/src/main/java/ua/com/javarush/gnew/contactm/repository/ContactRepository.java index 515e448..4614513 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/repository/ContactRepository.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/repository/ContactRepository.java @@ -1,6 +1,9 @@ package ua.com.javarush.gnew.contactm.repository; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import ua.com.javarush.gnew.contactm.entity.Contact; -public interface ContactRepository extends JpaRepository {} +public interface ContactRepository extends JpaRepository { + List findAllByNameContainingIgnoreCase(String name); +} diff --git a/src/main/java/ua/com/javarush/gnew/contactm/services/ContactService.java b/src/main/java/ua/com/javarush/gnew/contactm/services/ContactService.java index ddd8c35..3cc99d3 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/services/ContactService.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/services/ContactService.java @@ -1,6 +1,12 @@ package ua.com.javarush.gnew.contactm.services; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import ua.com.javarush.gnew.contactm.DTOs.ContactDTO; import ua.com.javarush.gnew.contactm.entity.Contact; @@ -9,24 +15,60 @@ @Service @RequiredArgsConstructor +@CacheConfig(cacheNames = "contacts") // all methods will use the "contacts" cache by default public class ContactService { private final ContactRepository contactRepository; private final ContactMapper contactMapper; - public void save(Contact contact) { - contactRepository.save(contact); + /** Save a Contact entity and update the cache entry for it. */ + @CachePut(key = "#contact.id") + public Contact save(Contact contact) { + return contactRepository.save(contact); } - public void save(ContactDTO contact) { - contactRepository.save(contactMapper.toEntity(contact)); + /** Save via DTO and update the cache entry for the resulting Contact. */ + @CachePut(key = "#result.id") + public Contact save(ContactDTO contactDto) { + Contact contact = contactMapper.toEntity(contactDto); + return contactRepository.save(contact); } - public Contact findById(Long id) { - return contactRepository.findById(id).orElse(null); + /** + * Read-through cache: will return from cache if present, otherwise load from DB and cache it. + * Returns Optional for better null handling. + */ + @Cacheable(key = "#id") + public Optional findById(Long id) { + return contactRepository.findById(id); } + /** Check if a contact exists by ID. */ + @Cacheable(key = "'exists-' + #id") + public boolean existsById(Long id) { + return contactRepository.existsById(id); + } + + /** Delete from the DB and evict the cache entry. */ + @CacheEvict(key = "#id") public void delete(Long id) { contactRepository.deleteById(id); } + + /** Delete by ID from the DB and evict the cache entry. */ + @CacheEvict(key = "#id") + public void deleteById(Long id) { + contactRepository.deleteById(id); + } + + @Cacheable(key = "'all'") + public List findAll() { + return contactRepository.findAll(); + } + + // find all by name + @Cacheable(key = "#name") + public Iterable findAllByName(String name) { + return contactRepository.findAllByNameContainingIgnoreCase(name); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 031ba8e..e09023e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,10 +7,22 @@ spring: jpa: hibernate: ddl-auto: update + data: + redis: + host: ${REDIS_HOST:localhost} # falls back to localhost if not set + port: ${REDIS_PORT:6379} # falls back to 6379 + password: ${REDIS_PASSWORD:} # empty if not set + ssl: + enabled: ${REDIS_SSL:false} # enable SSL if you set REDIS_SSL=true + lettuce: + pool: + max-active: ${REDIS_MAX_ACTIVE:8} + max-idle: ${REDIS_MAX_IDLE:8} + min-idle: ${REDIS_MIN_IDLE:0} show-sql: true properties: hibernate: - format_sql: true + format_sql: true logging: level: ua.com.javarush.gnew.contactm: DEBUG diff --git a/src/test/java/ua/com/javarush/gnew/contactm/controller/rest/ContactControllerApiTest.java b/src/test/java/ua/com/javarush/gnew/contactm/controller/rest/ContactControllerApiTest.java index b0dddd0..fbd9455 100644 --- a/src/test/java/ua/com/javarush/gnew/contactm/controller/rest/ContactControllerApiTest.java +++ b/src/test/java/ua/com/javarush/gnew/contactm/controller/rest/ContactControllerApiTest.java @@ -5,6 +5,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; @@ -16,7 +17,7 @@ import ua.com.javarush.gnew.contactm.DTOs.ContactDTO; import ua.com.javarush.gnew.contactm.entity.Contact; import ua.com.javarush.gnew.contactm.mapper.ContactMapper; -import ua.com.javarush.gnew.contactm.repository.ContactRepository; +import ua.com.javarush.gnew.contactm.services.ContactService; @WebMvcTest( value = ContactControllerApi.class, @@ -25,7 +26,7 @@ class ContactControllerApiTest { @Autowired private MockMvc mvc; - @MockitoBean private ContactRepository contactRepository; + @MockitoBean private ContactService contactService; @MockitoBean private ContactMapper contactMapper; @@ -39,7 +40,7 @@ void getContact_ShouldReturnContactDTOAndStatus200WhenContactExists() throws Exc ContactDTO contactDTO = ContactDTO.builder().id(id).name(name).build(); - when(contactRepository.findById(id)).thenReturn(java.util.Optional.of(contact)); + when(contactService.findById(id)).thenReturn(Optional.ofNullable(contact)); when(contactMapper.toDto(contact)).thenReturn(contactDTO); // Act & Assert @@ -51,4 +52,18 @@ void getContact_ShouldReturnContactDTOAndStatus200WhenContactExists() throws Exc .andExpect(MockMvcResultMatchers.jsonPath("$.id").value(id)) .andExpect(MockMvcResultMatchers.jsonPath("$.name").value(name)); } + + @Test + void getContact_ShouldReturnStatus404WhenContactNotExists() throws Exception { + // Arrange + long id = 1L; + + when(contactService.findById(id)).thenReturn(Optional.empty()); + + // Act & Assert + String path = "/api/v1/contact"; + + mvc.perform(get(path).param("id", "1").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } } From 3354035e09dea617a832a4b86c741739d1774ca0 Mon Sep 17 00:00:00 2001 From: oleksandr-jr Date: Wed, 2 Jul 2025 18:53:35 +0200 Subject: [PATCH 2/3] Refactor `RedisConfig` for better modularity and reuse - Extracted `ObjectMapper` and JSON serializer beans for improved maintainability. - Enhanced `RedisTemplate` and `RedisCacheManager` initialization with cleaner dependency injection. - Added detailed JavaDoc for better code clarity. --- .../gnew/contactm/config/RedisConfig.java | 92 +++++++++++-------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java b/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java index 1106c0a..593e05c 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module; import java.time.Duration; -import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -13,56 +13,76 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; -@Getter -@Configuration @Slf4j +@Configuration +@RequiredArgsConstructor public class RedisConfig { + /** + * Shared ObjectMapper with Hibernate support. + */ @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate tpl = new RedisTemplate<>(); - tpl.setConnectionFactory(connectionFactory); + public ObjectMapper redisObjectMapper() { + Hibernate6Module hibernateModule = new Hibernate6Module(); + hibernateModule.configure(Hibernate6Module.Feature.FORCE_LAZY_LOADING, false); + hibernateModule.configure( + Hibernate6Module.Feature.SERIALIZE_IDENTIFIER_FOR_LAZY_NOT_LOADED_OBJECTS, true); - // key serializer - tpl.setKeySerializer(new StringRedisSerializer()); - tpl.setHashKeySerializer(new StringRedisSerializer()); + return new ObjectMapper().registerModule(hibernateModule); + } - // JSON value serializer with Hibernate support - ObjectMapper objectMapper = new ObjectMapper(); - Hibernate6Module hibernate6Module = new Hibernate6Module(); - hibernate6Module.configure(Hibernate6Module.Feature.FORCE_LAZY_LOADING, false); - hibernate6Module.configure( - Hibernate6Module.Feature.SERIALIZE_IDENTIFIER_FOR_LAZY_NOT_LOADED_OBJECTS, true); - objectMapper.registerModule(hibernate6Module); + /** + * Generic JSON serializer using the shared ObjectMapper. + */ + @Bean + public RedisSerializer genericJsonSerializer(ObjectMapper redisObjectMapper) { + return new GenericJackson2JsonRedisSerializer(redisObjectMapper); + } - GenericJackson2JsonRedisSerializer jsonSer = - new GenericJackson2JsonRedisSerializer(objectMapper); - tpl.setValueSerializer(jsonSer); - tpl.setHashValueSerializer(jsonSer); + /** + * A RedisTemplate that uses String keys and JSON‐serialized values. + */ + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory, + RedisSerializer genericJsonSerializer + ) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // key serializers + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + template.setKeySerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); - tpl.afterPropertiesSet(); - log.info("Redis template initialized"); - return tpl; + // value serializers + template.setValueSerializer(genericJsonSerializer); + template.setHashValueSerializer(genericJsonSerializer); + + template.afterPropertiesSet(); + log.info("RedisTemplate initialized"); + return template; } + /** + * RedisCacheManager that applies a 60‐minute TTL and JSON serialization. + */ @Bean - public RedisCacheManager cacheManager(RedisConnectionFactory cf) { - ObjectMapper objectMapper = new ObjectMapper(); - Hibernate6Module hibernate6Module = new Hibernate6Module(); - hibernate6Module.configure(Hibernate6Module.Feature.FORCE_LAZY_LOADING, false); - hibernate6Module.configure( - Hibernate6Module.Feature.SERIALIZE_IDENTIFIER_FOR_LAZY_NOT_LOADED_OBJECTS, true); - objectMapper.registerModule(hibernate6Module); - - RedisCacheConfiguration defaultCfg = - RedisCacheConfiguration.defaultCacheConfig() + public RedisCacheManager cacheManager( + RedisConnectionFactory connectionFactory, + RedisSerializer genericJsonSerializer + ) { + RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(60)) .serializeValuesWith( - RedisSerializationContext.SerializationPair.fromSerializer( - new GenericJackson2JsonRedisSerializer(objectMapper))); + RedisSerializationContext.SerializationPair.fromSerializer(genericJsonSerializer) + ); - return RedisCacheManager.builder(cf).cacheDefaults(defaultCfg).build(); + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(cacheConfig) + .build(); } } From 6dba5813fe0e7c7d27eab056b986585c374847c9 Mon Sep 17 00:00:00 2001 From: oleksandr-jr Date: Wed, 2 Jul 2025 19:06:40 +0200 Subject: [PATCH 3/3] Code format --- .../gnew/contactm/config/RedisConfig.java | 36 ++++++------------- .../controller/web/ContactController.java | 2 +- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java b/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java index 593e05c..9440538 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/config/RedisConfig.java @@ -21,35 +21,27 @@ @RequiredArgsConstructor public class RedisConfig { - /** - * Shared ObjectMapper with Hibernate support. - */ + /** Shared ObjectMapper with Hibernate support. */ @Bean public ObjectMapper redisObjectMapper() { Hibernate6Module hibernateModule = new Hibernate6Module(); hibernateModule.configure(Hibernate6Module.Feature.FORCE_LAZY_LOADING, false); hibernateModule.configure( - Hibernate6Module.Feature.SERIALIZE_IDENTIFIER_FOR_LAZY_NOT_LOADED_OBJECTS, true); + Hibernate6Module.Feature.SERIALIZE_IDENTIFIER_FOR_LAZY_NOT_LOADED_OBJECTS, true); return new ObjectMapper().registerModule(hibernateModule); } - /** - * Generic JSON serializer using the shared ObjectMapper. - */ + /** Generic JSON serializer using the shared ObjectMapper. */ @Bean public RedisSerializer genericJsonSerializer(ObjectMapper redisObjectMapper) { return new GenericJackson2JsonRedisSerializer(redisObjectMapper); } - /** - * A RedisTemplate that uses String keys and JSON‐serialized values. - */ + /** A RedisTemplate that uses String keys and JSON‐serialized values. */ @Bean public RedisTemplate redisTemplate( - RedisConnectionFactory connectionFactory, - RedisSerializer genericJsonSerializer - ) { + RedisConnectionFactory connectionFactory, RedisSerializer genericJsonSerializer) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); @@ -67,22 +59,16 @@ public RedisTemplate redisTemplate( return template; } - /** - * RedisCacheManager that applies a 60‐minute TTL and JSON serialization. - */ + /** RedisCacheManager that applies a 60‐minute TTL and JSON serialization. */ @Bean public RedisCacheManager cacheManager( - RedisConnectionFactory connectionFactory, - RedisSerializer genericJsonSerializer - ) { - RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig() + RedisConnectionFactory connectionFactory, RedisSerializer genericJsonSerializer) { + RedisCacheConfiguration cacheConfig = + RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(60)) .serializeValuesWith( - RedisSerializationContext.SerializationPair.fromSerializer(genericJsonSerializer) - ); + RedisSerializationContext.SerializationPair.fromSerializer(genericJsonSerializer)); - return RedisCacheManager.builder(connectionFactory) - .cacheDefaults(cacheConfig) - .build(); + return RedisCacheManager.builder(connectionFactory).cacheDefaults(cacheConfig).build(); } } diff --git a/src/main/java/ua/com/javarush/gnew/contactm/controller/web/ContactController.java b/src/main/java/ua/com/javarush/gnew/contactm/controller/web/ContactController.java index 5d078e8..380697c 100644 --- a/src/main/java/ua/com/javarush/gnew/contactm/controller/web/ContactController.java +++ b/src/main/java/ua/com/javarush/gnew/contactm/controller/web/ContactController.java @@ -9,10 +9,10 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import ua.com.javarush.gnew.contactm.DTOs.ContactDTO; -import ua.com.javarush.gnew.contactm.entity.Contact; import ua.com.javarush.gnew.contactm.DTOs.EmailDTO; import ua.com.javarush.gnew.contactm.DTOs.PhoneDTO; import ua.com.javarush.gnew.contactm.DTOs.SocialNetworkDTO; +import ua.com.javarush.gnew.contactm.entity.Contact; import ua.com.javarush.gnew.contactm.mapper.ContactMapper; import ua.com.javarush.gnew.contactm.services.CloudinaryService; import ua.com.javarush.gnew.contactm.services.ContactService;