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

Simon Allegraud - Technical test #15

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions .sdkmanrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
java=17.0.7-graal
maven=3.9.6
mvnd=1.0-m8-m40
16 changes: 14 additions & 2 deletions technical-test-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- third party dependencies -->
<dependency>
Expand Down Expand Up @@ -61,16 +65,24 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<version>1.18.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<version>3.4.25</version>
<scope>test</scope>
</dependency>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package technical.test.api.representations;

import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.With;

@With
@Builder
public record AuthorRepresentation(@NotBlank String firstname,
@NotBlank String lastname,
@NotNull Integer birthdate,
@Nullable String id) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package technical.test.api.representations;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import org.hibernate.validator.constraints.ISBN;

@Builder
public record BookRepresentation(
@ISBN String isbn,
@NotBlank String title,
@NotNull Integer releaseDate,
@NotBlank String authorId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package technical.test.api.services;

import jakarta.annotation.Nullable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import technical.test.api.representations.AuthorRepresentation;
import technical.test.api.representations.BookRepresentation;
import technical.test.api.storage.models.Author;
import technical.test.api.storage.models.Book;

// TODO: remove methods with multiple arguments, they'll probably cause bugs in the future (mistake in argument ordering)
// TODO: remove methods exposing the 'storage.models' classes
public interface LibraryService {
Mono<Book> registerBook(String isbn, String title, Integer releaseDate, String authorId);

Mono<BookRepresentation> registerBookRepresentation(BookRepresentation book);

Mono<Author> registerAuthor(String firstname, String lastname, Integer birthdate);

Mono<AuthorRepresentation> registerAuthorRepresentation(AuthorRepresentation author);

Flux<Book> findBookByAuthorAndDateBetween(@Nullable String author, @Nullable Integer releaseDateMin, @Nullable Integer releaseDateMinMax);

Flux<Book> findAllBooks();

Flux<BookRepresentation> findBookRepresentationByAuthorAndDateBetween(@Nullable String author, @Nullable Integer releaseDateMin, @Nullable Integer releaseDateMinMax);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package technical.test.api.services;

import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import technical.test.api.representations.AuthorRepresentation;
import technical.test.api.representations.BookRepresentation;
import technical.test.api.services.mapper.AuthorMapper;
import technical.test.api.services.mapper.BookMapper;
import technical.test.api.storage.models.Author;
import technical.test.api.storage.models.Book;
import technical.test.api.storage.repositories.AuthorRepository;
import technical.test.api.storage.repositories.BookRepository;

@Service
record LibraryServiceImpl(
BookMapper bookMapper,
AuthorMapper authorMapper,
BookRepository bookRepository,
AuthorRepository authorRepository) implements LibraryService {
@Override
public Mono<Book> registerBook(String isbn, String title, Integer releaseDate, String authorId) {
var entity = bookMapper.map(isbn, title, releaseDate, authorId);

return bookRepository.save(entity);
}

@Override
public Mono<BookRepresentation> registerBookRepresentation(BookRepresentation book) {
var entity = bookMapper.map(book);

return bookRepository.save(entity)
.map(bookMapper::map);
}

@Override
public Mono<Author> registerAuthor(String firstname, String lastname, Integer birthdate) {
var entity = authorMapper.map(firstname, lastname, birthdate);

return authorRepository.save(entity);
}

@Override
public Mono<AuthorRepresentation> registerAuthorRepresentation(AuthorRepresentation author) {
var entity = authorMapper.map(author);

return authorRepository.save(entity).map(authorMapper::map);
}

@Override
public Flux<Book> findBookByAuthorAndDateBetween(String author, Integer releaseDateMin, Integer releaseDateMax) {
return bookRepository.findBookByAuthorIdAndReleaseDateBetween(author, releaseDateMin, releaseDateMax);
}

@Override
public Flux<Book> findAllBooks() {
return bookRepository.findAll();
}

@Override
public Flux<BookRepresentation> findBookRepresentationByAuthorAndDateBetween(String author, Integer releaseDateMin, Integer releaseDateMax) {
return findBookByAuthorAndDateBetween(author, releaseDateMin, releaseDateMax)
.map(bookMapper::map);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package technical.test.api.services.mapper;

import org.mapstruct.*;
import technical.test.api.representations.AuthorRepresentation;
import technical.test.api.storage.models.Author;

import static org.mapstruct.MappingConstants.ComponentModel.SPRING;

// TODO: configure all the mapstruct stuff (componentModel,unmappedTargetPolicy, etc) globally
@Mapper(componentModel = SPRING, builder = @Builder(disableBuilder = true))
public interface AuthorMapper {

Author map(String firstname, String lastname, Integer birthdate);

Author map(AuthorRepresentation author);

AuthorRepresentation map(Author author);

default String generateId(String firstname, String lastname) { // ⚠️ WARNING: this smells like a bug
return "%s_%s".formatted(firstname, lastname).toLowerCase();
}

@AfterMapping
default Author afterMapping(@MappingTarget Author author) { // ⚠️ WARNING: this smells like a bug
if (author.getId() == null) {
return author.withId(generateId(author.getFirstname(), author.getLastname()));
}
return author;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package technical.test.api.services.mapper;

import org.mapstruct.Mapper;
import technical.test.api.representations.BookRepresentation;
import technical.test.api.storage.models.Book;

import static org.mapstruct.MappingConstants.ComponentModel.SPRING;

@Mapper(componentModel = SPRING)
public interface BookMapper {

Book map(String isbn, String title, Integer releaseDate, String authorId);

Book map(BookRepresentation bookRepresentation);

BookRepresentation map(Book book);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package technical.test.api.storage.models;

import lombok.Builder;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import lombok.With;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

@With
@Value
@Builder
@Document
@RequiredArgsConstructor
public class Author {
@Id
String id;
String firstname;
String lastname;
Integer birthdate;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package technical.test.api.storage.models;

import lombok.Builder;
import lombok.Value;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

@Value
@Builder
@Document
@FieldNameConstants
public class Book {
@Id
String id;
String isbn;
String title;
Integer releaseDate;
String authorId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package technical.test.api.storage.repositories;

import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.stereotype.Repository;
import technical.test.api.storage.models.Author;

@Repository
public interface AuthorRepository extends ReactiveCrudRepository<Author, String> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package technical.test.api.storage.repositories;

import lombok.RequiredArgsConstructor;
import lombok.experimental.Delegate;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import technical.test.api.storage.models.Book;

import static java.util.Optional.ofNullable;
import static org.springframework.data.mongodb.core.query.Criteria.where;

@Repository
@RequiredArgsConstructor
public class BookRepository {
private final ReactiveMongoTemplate mongoTemplate;
@Delegate
private final SpringBookRepository delegateRepository;

// Flux<Book> findBookByAuthorIdAndReleaseDateBetween(String authorId, Integer releaseDateMin, Integer releaseDateMax);

// Nullable parameters are a very bad idea...
public Flux<Book> findBookByAuthorIdAndReleaseDateBetween(String authorId, Integer releaseDateMin, Integer releaseDateMax) {

Query query = new Query();
ofNullable(authorId).map(id -> where(Book.Fields.authorId).is(id)).ifPresent(query::addCriteria);
if (releaseDateMin != null && releaseDateMax != null) {
// Due to limitations of the com.mongodb.BasicDocument, you can't add a second 'releaseDate' criteria
query.addCriteria(where(Book.Fields.releaseDate).gte(releaseDateMin).lte(releaseDateMax));
} else {
ofNullable(releaseDateMin).map(date -> where(Book.Fields.releaseDate).gte(date)).ifPresent(query::addCriteria);
ofNullable(releaseDateMax).map(date -> where(Book.Fields.releaseDate).lte(date)).ifPresent(query::addCriteria);
}

return mongoTemplate.find(query, Book.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package technical.test.api.storage.repositories;

import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.stereotype.Repository;
import technical.test.api.storage.models.Book;

// This boilerplate repository is not meant to be used directly (it should stay package private), it's sole purpose is
// to declare the @Repository in order for Spring to inject it in BookRepository
@Repository
interface SpringBookRepository extends ReactiveCrudRepository<Book, String> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package technical.test.api.web;

import jakarta.annotation.Nullable;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import technical.test.api.representations.AuthorRepresentation;
import technical.test.api.representations.BookRepresentation;
import technical.test.api.services.LibraryService;

@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/library")
public class LibraryController {
private final LibraryService libraryService;

@PostMapping("/authors")
public Mono<AuthorRepresentation> registerAuthor(@Valid AuthorRepresentation author) {
var cleanedAuthor = author.withId(null); // TODO: find out what the original author meant about this ID

return libraryService.registerAuthorRepresentation(cleanedAuthor);
}

@PostMapping("/books")
// FIXME: test have a different input/output naming :(, we can't re-use the "representation" object...
public Mono<BookRepresentation> registerAuthor(
@NotBlank String isbn, // the tests expect a 200 from an invalid ISBN, we can't use the @ISBN validator
@NotBlank String title,
@NotNull Integer releaseDateYear,
@NotBlank String authorRefId) {
var book = BookRepresentation.builder()
.isbn(isbn)
.title(title)
.releaseDate(releaseDateYear)
.authorId(authorRefId)
.build();

return libraryService.registerBookRepresentation(book);
}

@GetMapping("/books")
public Flux<BookRepresentation> getBooks(@Nullable String authorRefId,
@Nullable Integer yearFrom,
@Nullable Integer yearTo) {
return libraryService.findBookRepresentationByAuthorAndDateBetween(authorRefId, yearFrom, yearTo);
}
}
Loading