Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Opened /api/catalog/books/{book_id} endpoint for deleting book #97

Merged
merged 18 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@
<version>2.6.3</version>
<scope>test</scope>
</dependency>
<dependency>
djordjemijailovicpd marked this conversation as resolved.
Show resolved Hide resolved
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/productdock/adapter/in/web/DeleteBookApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.productdock.adapter.in.web;

import com.productdock.application.port.in.DeleteBookUseCase;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/api/catalog/books")
djordjemijailovicpd marked this conversation as resolved.
Show resolved Hide resolved
record DeleteBookApi(DeleteBookUseCase deleteBookUseCase) {

@DeleteMapping("/{bookId}")
public void deleteBook(@PathVariable("bookId") Long bookId) {
log.debug("DELETE BOOK request received with book id: {}", bookId);
deleteBookUseCase.deleteBook(bookId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.productdock.adapter.out.kafka;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.productdock.adapter.out.kafka.publisher.KafkaPublisher;
import com.productdock.application.port.out.messaging.DeleteBookMessagingOutPort;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.concurrent.ExecutionException;

@Component
@RequiredArgsConstructor
class DeletedBookMessagePublisher implements DeleteBookMessagingOutPort {

@Value("${spring.kafka.topic.delete-book}")
private String kafkaTopic;
private final KafkaPublisher publisher;

@Override
public void sendMessage(Long bookId) throws ExecutionException, InterruptedException, JsonProcessingException {
NenadJeckovic marked this conversation as resolved.
Show resolved Hide resolved
publisher.sendMessage(bookId, kafkaTopic);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public Book save(Book book) {
return bookMapper.toDomain(bookRepository.save(bookJpaEntity));
}

@Override
public void deleteById(Long bookId) {
bookRepository.deleteById(bookId);
}

private Set<TopicJpaEntity> populateBookTopics(List<Book.Topic> topics) {
var topicEntities = topicRepository.findByIds(topics.stream().map(Book.Topic::getId).toList());
if (topics.size() != topicEntities.size())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.productdock.adapter.out.web;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.productdock.application.port.out.web.RentalsClient;
import com.productdock.domain.BookRentalState;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
import org.springframework.web.util.DefaultUriBuilderFactory;

import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Collection;

@Slf4j
@Component
public class RentalsApiClient implements RentalsClient {

private String rentalsServiceUrl;
private HttpClient client = HttpClient.newHttpClient();

private ObjectMapper objectMapper = new ObjectMapper();

public RentalsApiClient(@Value("${rental.service.url}/api/rental/book/") String rentalsServiceUrl) {
this.rentalsServiceUrl = rentalsServiceUrl;
}

@Override
public Collection<BookRentalState> getRentals(Long bookId) throws IOException, InterruptedException {
var jwt = ((Jwt) SecurityContextHolder.getContext().getAuthentication().getCredentials()).getTokenValue();
var uri = new DefaultUriBuilderFactory(rentalsServiceUrl)
.builder()
.path(bookId.toString())
.path("/rentals")
.build();
var request = HttpRequest.newBuilder()
.uri(uri)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + jwt)
.GET()
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
return objectMapper.readValue(response.body(), new TypeReference<>() {
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.productdock.application.port.in;

public interface DeleteBookUseCase {

void deleteBook(Long bookId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.productdock.application.port.out.messaging;

import com.fasterxml.jackson.core.JsonProcessingException;

import java.util.concurrent.ExecutionException;

public interface DeleteBookMessagingOutPort {
void sendMessage(Long bookId) throws ExecutionException, InterruptedException, JsonProcessingException;
NenadJeckovic marked this conversation as resolved.
Show resolved Hide resolved

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public interface BookPersistenceOutPort {
Optional<Book> findByTitleAndAuthor(String title, String author);

Book save(Book book);

void deleteById(Long bookId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.productdock.application.port.out.web;

import com.productdock.domain.BookRentalState;

import java.io.IOException;
import java.util.Collection;

public interface RentalsClient {

Collection<BookRentalState> getRentals(Long bookId) throws IOException, InterruptedException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.productdock.application.service;

import com.productdock.application.port.in.DeleteBookUseCase;
import com.productdock.application.port.out.messaging.DeleteBookMessagingOutPort;
import com.productdock.application.port.out.persistence.BookPersistenceOutPort;
import com.productdock.application.port.out.web.RentalsClient;
import com.productdock.domain.BookRentalState;
import com.productdock.domain.exception.BookNotFoundException;
import com.productdock.domain.exception.DeleteBookException;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collection;

@Slf4j
@Service
@RequiredArgsConstructor
class DeleteBookService implements DeleteBookUseCase {

private final BookPersistenceOutPort bookRepository;

private final DeleteBookMessagingOutPort deleteBookMessagingOutPort;

private final RentalsClient rentalsClient;

@Override
@SneakyThrows
@Transactional
public void deleteBook(Long bookId) {
validateBookAvailability(bookId);
bookRepository.deleteById(bookId);
deleteBookMessagingOutPort.sendMessage(bookId);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if sendMessage fails for some reason? Do we want to delete a book without notifying everyone else?
Any idea how to prevent this?

log.debug("deleted book with id: {}", bookId);
}

@SneakyThrows
private void validateBookAvailability(Long bookId) {
if (bookRepository.findById(bookId).isEmpty()) {
NenadJeckovic marked this conversation as resolved.
Show resolved Hide resolved
throw new BookNotFoundException("Book not found.");
}
var bookRentals = rentalsClient.getRentals(bookId);
if (!bookRentals.isEmpty()) {
throw new DeleteBookException(createRentalMessage(bookRentals));
}
}

private String createRentalMessage(Collection<BookRentalState> bookRentals) {
var message = "Book in use by: ";
var punctuation = "";
for (var rental : bookRentals) {
var status = rental.status().toString().toLowerCase();
var userName = rental.user().fullName();

message = message.concat(punctuation).concat(userName).concat(" (").concat(status).concat(")");
punctuation = ", ";
}

return message;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will this message look like if the bookRentals has more than one element? Let's say we have two copies of the same book and both are rented.
My guess it would be something like:
"Book is rented by sinisa.tutus. rented by nenad.jeckovic"
I'm not saying it is bad, just is this what you wanted (assuming I recreated the message correctly 😄 )?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also if this happens if (rental.status() == null || rental.user() == null) it will return Cannot read rental status. regardless of what we generated in the message before (also the case when we have more than one element in the list)

}
}
5 changes: 4 additions & 1 deletion src/main/java/com/productdock/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {

private static final String ROLE_ADMIN = "SCOPE_ROLE_ADMIN";

@Value("${jwt.public.key}")
RSAPublicKey key;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests(authorize -> authorize.antMatchers("/actuator/**").permitAll()
.antMatchers(HttpMethod.POST, "/api/catalog/books").hasAuthority("SCOPE_ROLE_ADMIN")
.antMatchers(HttpMethod.POST, "/api/catalog/books").hasAuthority(ROLE_ADMIN)
.antMatchers(HttpMethod.DELETE, "/api/catalog/books/{bookId}").hasAuthority(ROLE_ADMIN)
.anyRequest().authenticated())
.cors().and()
.oauth2ResourceServer().jwt();
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/com/productdock/domain/BookRentalState.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.productdock.domain;

import java.util.Date;

public record BookRentalState(UserProfile user, RentalStatus status, Date date) {
}

6 changes: 6 additions & 0 deletions src/main/java/com/productdock/domain/RentalStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.productdock.domain;

public enum RentalStatus {
RENTED,
RESERVED
}
5 changes: 5 additions & 0 deletions src/main/java/com/productdock/domain/UserProfile.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.productdock.domain;


public record UserProfile(String fullName, String image, String email) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.productdock.domain.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(code = HttpStatus.NOT_FOUND)
djordjemijailovicpd marked this conversation as resolved.
Show resolved Hide resolved
public class BookNotFoundException extends RuntimeException{

public BookNotFoundException(String message){ super(message);}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.productdock.domain.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(code = HttpStatus.BAD_REQUEST)
djordjemijailovicpd marked this conversation as resolved.
Show resolved Hide resolved
public class DeleteBookException extends RuntimeException {

public DeleteBookException(String message){ super(message);}
}
1 change: 1 addition & 0 deletions src/main/resources/application-local.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
USER_PROFILES_JWT_PUBLIC_KEY: src/main/resources/app-local.pub
RENTAL_SERVICE_URL: http://localhost:8083

KAFKA_SERVER_URL: localhost:9093
MYSQL_SERVER_URL: localhost:3308
Expand Down
5 changes: 5 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ spring:
topic:
book-rating: book-rating
insert-book: insert-book
delete-book: delete-book

jpa:
hibernate:
Expand All @@ -40,6 +41,10 @@ file-logging-enabled: ${LOG_TO_FILE}
jwt:
public.key: file:${USER_PROFILES_JWT_PUBLIC_KEY}

rental:
service:
url: ${RENTAL_SERVICE_URL}

management:
server:
port: 8087
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.productdock.adapter.in.web;

import com.productdock.application.port.in.DeleteBookUseCase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
public class DeleteBookApiShould {

public static final long BOOK_ID = 1;
@InjectMocks
private DeleteBookApi deleteBookApi;
@Mock
private DeleteBookUseCase deleteBookUseCase;

@Test
void deleteBook() {

deleteBookApi.deleteBook(BOOK_ID);

verify(deleteBookUseCase).deleteBook(BOOK_ID);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.productdock.adapter.out.kafka;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.productdock.adapter.out.kafka.publisher.KafkaPublisher;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.concurrent.ExecutionException;

import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
public class DeleteBookMessagePublisherShould {
public static final long BOOK_ID = 1;

@InjectMocks
private DeletedBookMessagePublisher deletedBookMessagePublisher;
@Mock
private KafkaPublisher publisher;

@Test
void sendMessage() throws ExecutionException, InterruptedException, JsonProcessingException {

deletedBookMessagePublisher.sendMessage(BOOK_ID);

verify(publisher).sendMessage(BOOK_ID, null);
}
}
Loading
Loading