Move backend files into a sub folder.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package org.codiki.application.category;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class CategoryCreationValidator {
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package org.codiki.application.category;
|
||||
|
||||
import static java.util.Collections.emptyList;
|
||||
import static java.util.Objects.isNull;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.codiki.domain.category.model.builder.CategoryBuilder.aCategory;
|
||||
import org.codiki.domain.category.exception.CategoryDeletionException;
|
||||
import org.codiki.domain.category.exception.CategoryEditionException;
|
||||
import org.codiki.domain.category.exception.CategoryNotFoundException;
|
||||
import org.codiki.domain.category.model.Category;
|
||||
import org.codiki.domain.category.model.builder.CategoryBuilder;
|
||||
import org.codiki.domain.category.port.CategoryPort;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class CategoryUseCases {
|
||||
private final CategoryPort categoryPort;
|
||||
|
||||
public CategoryUseCases(CategoryPort categoryPort) {
|
||||
this.categoryPort = categoryPort;
|
||||
}
|
||||
|
||||
public Optional<Category> findById(UUID categoryId) {
|
||||
return categoryPort.findById(categoryId);
|
||||
}
|
||||
|
||||
public boolean existsById(UUID categoryId) {
|
||||
return categoryPort.existsById(categoryId);
|
||||
}
|
||||
|
||||
public Category createCategory(String name, List<UUID> subCategoryIds) {
|
||||
if (isNull(name)) {
|
||||
throw new CategoryEditionException("name can not be empty");
|
||||
}
|
||||
|
||||
List<Category> subCategories = emptyList();
|
||||
if (!isNull(subCategoryIds)) {
|
||||
try {
|
||||
subCategories = categoryPort.findAllByIds(subCategoryIds);
|
||||
} catch (CategoryNotFoundException exception) {
|
||||
throw new CategoryEditionException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
Category newCategory = aCategory()
|
||||
.withId(UUID.randomUUID())
|
||||
.withName(name)
|
||||
.withSubCategories(subCategories)
|
||||
.build();
|
||||
|
||||
categoryPort.save(newCategory);
|
||||
|
||||
return newCategory;
|
||||
}
|
||||
|
||||
public Category updateCategory(UUID categoryId, String name, List<UUID> subCategoryIds) {
|
||||
if (isNull(name) && isNull(subCategoryIds)) {
|
||||
throw new CategoryEditionException("no any field is filled");
|
||||
}
|
||||
|
||||
Category categoryToUpdate = categoryPort.findById(categoryId)
|
||||
.orElseThrow(() -> new CategoryNotFoundException(categoryId));
|
||||
|
||||
CategoryBuilder categoryBuilder = aCategory()
|
||||
.basedOn(categoryToUpdate);
|
||||
|
||||
if (!isNull(name)) {
|
||||
categoryBuilder.withName(name);
|
||||
}
|
||||
|
||||
if (!isNull(subCategoryIds)) {
|
||||
List<Category> subCategories = emptyList();
|
||||
|
||||
if (!subCategoryIds.isEmpty()) {
|
||||
try {
|
||||
subCategories = categoryPort.findAllByIds(subCategoryIds);
|
||||
} catch (CategoryNotFoundException exception) {
|
||||
throw new CategoryEditionException(exception);
|
||||
}
|
||||
}
|
||||
categoryBuilder.withSubCategories(subCategories);
|
||||
}
|
||||
|
||||
Category updatedCategory = categoryBuilder.build();
|
||||
categoryPort.save(updatedCategory);
|
||||
return updatedCategory;
|
||||
}
|
||||
|
||||
public void deleteCategory(UUID categoryId) {
|
||||
if (!categoryPort.existsById(categoryId)) {
|
||||
throw new CategoryNotFoundException(categoryId);
|
||||
}
|
||||
|
||||
if (categoryPort.existsAnyAssociatedPublication(categoryId)) {
|
||||
throw new CategoryDeletionException(categoryId, "some publications are associated to the category");
|
||||
}
|
||||
|
||||
categoryPort.deleteById(categoryId);
|
||||
}
|
||||
|
||||
public List<Category> getAll() {
|
||||
return categoryPort.findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.codiki.application.configuration;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@Configuration
|
||||
public class ServiceConfiguration {
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Clock clock() {
|
||||
return Clock.systemDefaultZone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.codiki.application.picture;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.codiki.domain.picture.model.builder.PictureBuilder.aPicture;
|
||||
import org.codiki.application.user.UserUseCases;
|
||||
import org.codiki.domain.exception.AuthenticationRequiredException;
|
||||
import org.codiki.domain.picture.model.Picture;
|
||||
import org.codiki.domain.picture.port.PicturePort;
|
||||
import org.codiki.domain.user.model.User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class PictureUseCases {
|
||||
private final PicturePort picturePort;
|
||||
private final UserUseCases userUseCases;
|
||||
|
||||
public PictureUseCases(PicturePort picturePort, UserUseCases userUseCases) {
|
||||
this.picturePort = picturePort;
|
||||
this.userUseCases = userUseCases;
|
||||
}
|
||||
|
||||
public Picture createPicture(File pictureFile) {
|
||||
User authenticatedUser = userUseCases.getAuthenticatedUser()
|
||||
.orElseThrow(AuthenticationRequiredException::new);
|
||||
|
||||
Picture newPicture = aPicture()
|
||||
.withId(UUID.randomUUID())
|
||||
.withPublisher(authenticatedUser)
|
||||
.withContentFile(pictureFile)
|
||||
.build();
|
||||
|
||||
picturePort.save(newPicture);
|
||||
|
||||
return newPicture;
|
||||
}
|
||||
|
||||
public void deletePicture(UUID pictureId) {
|
||||
picturePort.deleteById(pictureId);
|
||||
}
|
||||
|
||||
public Optional<Picture> findById(UUID pictureId) {
|
||||
return picturePort.findById(pictureId);
|
||||
}
|
||||
|
||||
public boolean existsById(UUID pictureId) {
|
||||
return picturePort.existsById(pictureId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.codiki.application.publication;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class KeyGenerator {
|
||||
private static final String ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
private static final int KEY_LENGTH = 14;
|
||||
|
||||
public String generateKey() {
|
||||
SecureRandom random = new SecureRandom();
|
||||
StringBuilder code = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < KEY_LENGTH; i++) {
|
||||
int randomIndex = random.nextInt(ALLOWED_CHARACTERS.length());
|
||||
code.append(ALLOWED_CHARACTERS.charAt(randomIndex));
|
||||
}
|
||||
|
||||
return code.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.codiki.application.publication;
|
||||
|
||||
import org.codiki.domain.publication.exception.PublicationEditionException;
|
||||
import org.codiki.domain.publication.model.PublicationEditionRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PublicationCreationRequestValidator {
|
||||
public void isValid(PublicationEditionRequest request) {
|
||||
if (request.title() == null) {
|
||||
throw new PublicationEditionException("title cannot be null");
|
||||
}
|
||||
|
||||
if (request.text() == null) {
|
||||
throw new PublicationEditionException("text cannot be null");
|
||||
}
|
||||
|
||||
if (request.description() == null) {
|
||||
throw new PublicationEditionException("description cannot be null");
|
||||
}
|
||||
|
||||
if (request.illustrationId() == null) {
|
||||
throw new PublicationEditionException("illustrationId cannot be null");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.codiki.application.publication;
|
||||
|
||||
import static java.util.stream.Collectors.toSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
|
||||
import static org.codiki.domain.publication.model.search.ComparisonType.EQUALS;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_ID;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_PSEUDO;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.CATEGORY_ID;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.DESCRIPTION;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.ID;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.KEY;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.TEXT;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.TITLE;
|
||||
import static org.springframework.util.ObjectUtils.isEmpty;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchField;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PublicationSearchCriteriaFactory {
|
||||
private static final Pattern ACCENT_LETTER_REGEX = Pattern.compile("[à-ü]|[À-Ü]");
|
||||
private static final List<PublicationSearchField> ID_SEARCH_FIELDS = List.of(ID, CATEGORY_ID, AUTHOR_ID);
|
||||
|
||||
public List<PublicationSearchCriterion> buildCriteria(String searchQuery) {
|
||||
Set<String> stringCriteria = Set.of(searchQuery.split(" "));
|
||||
|
||||
return stringCriteria.stream()
|
||||
.map(this::buildPublicationSearchCriterion)
|
||||
.flatMap(List::stream)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<PublicationSearchCriterion> buildPublicationSearchCriterion(String criterion) {
|
||||
List<PublicationSearchCriterion> result;
|
||||
|
||||
if (criterion.contains("=")) {
|
||||
String[] criterionParts = criterion.split("=");
|
||||
|
||||
if (criterionParts.length > 2) {
|
||||
result = buildDefaultContainsCriteria(criterion);
|
||||
} else {
|
||||
String criterionSearchFieldAsString = criterionParts[0];
|
||||
String criterionValue = criterionParts[1];
|
||||
|
||||
result = PublicationSearchField.from(criterionSearchFieldAsString)
|
||||
.map(searchField -> {
|
||||
List<PublicationSearchCriterion> criteria;
|
||||
if (ID_SEARCH_FIELDS.contains(searchField)) {
|
||||
criteria = convertToUuid(criterionValue)
|
||||
.map(uuidCriterion -> new PublicationSearchCriterion(searchField, EQUALS, uuidCriterion))
|
||||
.map(List::of)
|
||||
.orElse(buildDefaultContainsCriteria(criterion));
|
||||
} else {
|
||||
criteria = List.of(new PublicationSearchCriterion(searchField, EQUALS, criterionValue));
|
||||
}
|
||||
return criteria;
|
||||
})
|
||||
.orElse(buildDefaultContainsCriteria(criterion));
|
||||
}
|
||||
} else {
|
||||
result = buildDefaultContainsCriteria(criterion);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Optional<UUID> convertToUuid(String uuidValue) {
|
||||
Optional<UUID> result;
|
||||
try {
|
||||
result = Optional.of(UUID.fromString(uuidValue));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
result = Optional.empty();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<PublicationSearchCriterion> buildDefaultContainsCriteria(String criterion) {
|
||||
return List.of(
|
||||
new PublicationSearchCriterion(KEY, CONTAINS, criterion),
|
||||
new PublicationSearchCriterion(TITLE, CONTAINS, criterion),
|
||||
new PublicationSearchCriterion(TEXT, CONTAINS, criterion),
|
||||
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, criterion),
|
||||
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, criterion)
|
||||
);
|
||||
}
|
||||
|
||||
Set<String> splitAndSanitizeSearchCriterion(String searchQuery) {
|
||||
Set<String> result = new HashSet<>();
|
||||
|
||||
for (String fragment : searchQuery.split(" ")) {
|
||||
Set<String> subFragmentsFromAccentedCharactersSplitting = splitSubFragmentByAccentedCharacters(fragment);
|
||||
|
||||
if (isEmpty(subFragmentsFromAccentedCharactersSplitting)) {
|
||||
result.add(fragment);
|
||||
} else {
|
||||
result.addAll(subFragmentsFromAccentedCharactersSplitting);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Set<String> splitSubFragmentByAccentedCharacters(String fragment) {
|
||||
Set<String> result = new HashSet<>();
|
||||
|
||||
Matcher accentsMatcher = ACCENT_LETTER_REGEX.matcher(fragment);
|
||||
Set<String> accentedCharacters = new HashSet<>();
|
||||
while (accentsMatcher.find()) {
|
||||
accentedCharacters.add(accentsMatcher.group());
|
||||
}
|
||||
|
||||
if (!isEmpty(accentedCharacters)) {
|
||||
String joinedAccentedCharacters = String.join("", accentedCharacters);
|
||||
String[] subFragments = fragment.split(String.format("[%s]", joinedAccentedCharacters));
|
||||
|
||||
result = Stream.of(subFragments)
|
||||
.filter(subFragment -> subFragment.length() > 1)
|
||||
.collect(toSet());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.codiki.application.publication;
|
||||
|
||||
import static java.util.Objects.isNull;
|
||||
|
||||
import org.codiki.domain.publication.exception.PublicationEditionException;
|
||||
import org.codiki.domain.publication.model.PublicationEditionRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PublicationUpdateRequestValidator {
|
||||
public void isValid(PublicationEditionRequest request) {
|
||||
if (
|
||||
isNull(request.title()) &&
|
||||
isNull(request.text()) &&
|
||||
isNull(request.description()) &&
|
||||
isNull(request.illustrationId()) &&
|
||||
isNull(request.categoryId())
|
||||
) {
|
||||
throw new PublicationEditionException("no any field is filled");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package org.codiki.application.publication;
|
||||
|
||||
import static java.util.Objects.isNull;
|
||||
import java.time.Clock;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.codiki.domain.publication.model.builder.AuthorBuilder.anAuthor;
|
||||
import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication;
|
||||
import org.codiki.application.category.CategoryUseCases;
|
||||
import org.codiki.application.picture.PictureUseCases;
|
||||
import org.codiki.application.user.UserUseCases;
|
||||
import org.codiki.domain.category.exception.CategoryNotFoundException;
|
||||
import org.codiki.domain.exception.AuthenticationRequiredException;
|
||||
import org.codiki.domain.picture.exception.PictureNotFoundException;
|
||||
import org.codiki.domain.publication.exception.PublicationEditionException;
|
||||
import org.codiki.domain.publication.exception.PublicationNotFoundException;
|
||||
import org.codiki.domain.publication.exception.PublicationUpdateForbiddenException;
|
||||
import org.codiki.domain.publication.model.Publication;
|
||||
import org.codiki.domain.publication.model.PublicationEditionRequest;
|
||||
import org.codiki.domain.publication.model.builder.PublicationBuilder;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
import org.codiki.domain.publication.port.PublicationPort;
|
||||
import org.codiki.domain.user.model.User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class PublicationUseCases {
|
||||
private final CategoryUseCases categoryUseCases;
|
||||
private final Clock clock;
|
||||
private final KeyGenerator keyGenerator;
|
||||
private final PictureUseCases pictureUseCases;
|
||||
private final PublicationCreationRequestValidator publicationCreationRequestValidator;
|
||||
private final PublicationPort publicationPort;
|
||||
private final PublicationSearchCriteriaFactory publicationSearchCriteriaFactory;
|
||||
private final PublicationUpdateRequestValidator publicationUpdateRequestValidator;
|
||||
private final UserUseCases userUseCases;
|
||||
|
||||
public PublicationUseCases(
|
||||
CategoryUseCases categoryUseCases,
|
||||
Clock clock,
|
||||
KeyGenerator keyGenerator,
|
||||
PictureUseCases pictureUseCases,
|
||||
PublicationCreationRequestValidator publicationCreationRequestValidator,
|
||||
PublicationPort publicationPort,
|
||||
PublicationSearchCriteriaFactory publicationSearchCriteriaFactory,
|
||||
PublicationUpdateRequestValidator publicationUpdateRequestValidator,
|
||||
UserUseCases userUseCases
|
||||
) {
|
||||
this.categoryUseCases = categoryUseCases;
|
||||
this.clock = clock;
|
||||
this.keyGenerator = keyGenerator;
|
||||
this.publicationCreationRequestValidator = publicationCreationRequestValidator;
|
||||
this.publicationPort = publicationPort;
|
||||
this.publicationUpdateRequestValidator = publicationUpdateRequestValidator;
|
||||
this.userUseCases = userUseCases;
|
||||
this.pictureUseCases = pictureUseCases;
|
||||
this.publicationSearchCriteriaFactory = publicationSearchCriteriaFactory;
|
||||
}
|
||||
|
||||
public Publication createPublication(PublicationEditionRequest request) {
|
||||
publicationCreationRequestValidator.isValid(request);
|
||||
|
||||
User authenticatedUser = userUseCases.getAuthenticatedUser()
|
||||
.orElseThrow(AuthenticationRequiredException::new);
|
||||
|
||||
if (!categoryUseCases.existsById(request.categoryId())) {
|
||||
throw new PublicationEditionException(
|
||||
new CategoryNotFoundException(request.categoryId())
|
||||
);
|
||||
}
|
||||
|
||||
if (!pictureUseCases.existsById(request.illustrationId())) {
|
||||
throw new PublicationEditionException(
|
||||
new PictureNotFoundException(request.illustrationId())
|
||||
);
|
||||
}
|
||||
|
||||
Publication newPublication = aPublication()
|
||||
.withId(UUID.randomUUID())
|
||||
.withKey(keyGenerator.generateKey())
|
||||
.withTitle(request.title())
|
||||
.withText(request.text())
|
||||
.withDescription(request.description())
|
||||
.withCreationDate(ZonedDateTime.now(clock))
|
||||
.withIllustrationId(request.illustrationId())
|
||||
.withCategoryId(request.categoryId())
|
||||
.withAuthor(anAuthor().basedOn(authenticatedUser).build())
|
||||
.build();
|
||||
|
||||
publicationPort.save(newPublication);
|
||||
|
||||
return newPublication;
|
||||
}
|
||||
|
||||
public Publication updatePublication(UUID publicationId, PublicationEditionRequest request) {
|
||||
publicationUpdateRequestValidator.isValid(request);
|
||||
|
||||
Publication publicationToUpdate = publicationPort.findById(publicationId)
|
||||
.orElseThrow(() -> new PublicationNotFoundException(publicationId));
|
||||
|
||||
User authenticatedUser = userUseCases.getAuthenticatedUser()
|
||||
.orElseThrow(AuthenticationRequiredException::new);
|
||||
|
||||
if (!publicationToUpdate.author().id().equals(authenticatedUser.id())) {
|
||||
throw new PublicationUpdateForbiddenException();
|
||||
}
|
||||
|
||||
PublicationBuilder publicationBuilder = aPublication().basedOn(publicationToUpdate);
|
||||
|
||||
if (!isNull(request.title())) {
|
||||
publicationBuilder.withTitle(request.title());
|
||||
}
|
||||
|
||||
if (!isNull(request.text())) {
|
||||
publicationBuilder.withText(request.text());
|
||||
}
|
||||
|
||||
if (!isNull(request.description())) {
|
||||
publicationBuilder.withDescription(request.description());
|
||||
}
|
||||
|
||||
if (!isNull(request.illustrationId())) {
|
||||
if (!pictureUseCases.existsById(request.illustrationId())) {
|
||||
throw new PublicationEditionException(
|
||||
new PictureNotFoundException(request.illustrationId())
|
||||
);
|
||||
}
|
||||
publicationBuilder.withIllustrationId(request.illustrationId());
|
||||
}
|
||||
|
||||
if (!isNull(request.categoryId())) {
|
||||
if (!categoryUseCases.existsById(request.categoryId())) {
|
||||
throw new PublicationEditionException(
|
||||
new CategoryNotFoundException(request.categoryId())
|
||||
);
|
||||
}
|
||||
|
||||
publicationBuilder.withCategoryId(request.categoryId());
|
||||
}
|
||||
|
||||
Publication updatedPublication = publicationBuilder.build();
|
||||
|
||||
publicationPort.save(updatedPublication);
|
||||
|
||||
return updatedPublication;
|
||||
}
|
||||
|
||||
public void deletePublication(UUID publicationId) {
|
||||
Publication publicationToDelete = publicationPort.findById(publicationId)
|
||||
.orElseThrow(() -> new PublicationNotFoundException(publicationId));
|
||||
|
||||
User authenticatedUser = userUseCases.getAuthenticatedUser()
|
||||
.orElseThrow(AuthenticationRequiredException::new);
|
||||
|
||||
if (!publicationToDelete.author().id().equals(authenticatedUser.id())) {
|
||||
throw new PublicationUpdateForbiddenException();
|
||||
}
|
||||
|
||||
publicationPort.delete(publicationToDelete);
|
||||
}
|
||||
|
||||
public Optional<Publication> findById(UUID publicationId) {
|
||||
return publicationPort.findById(publicationId);
|
||||
}
|
||||
|
||||
public List<Publication> searchPublications(String searchQuery) {
|
||||
List<PublicationSearchCriterion> criteria = publicationSearchCriteriaFactory.buildCriteria(searchQuery);
|
||||
|
||||
return publicationPort.search(criteria);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.codiki.application.security;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
// This class allow to retrieve connected user information through "Authentication" object.
|
||||
@Component
|
||||
public class AuthenticationFacade {
|
||||
public Authentication getAuthentication() {
|
||||
return SecurityContextHolder.getContext().getAuthentication();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.codiki.application.security;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.application.user.UserUseCases;
|
||||
import org.codiki.application.security.model.CustomUserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
private final UserUseCases userUseCases;
|
||||
|
||||
public CustomUserDetailsService(UserUseCases userUseCases) {
|
||||
this.userUseCases = userUseCases;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String userIdAsString) throws UsernameNotFoundException {
|
||||
UUID userId = parseUserId(userIdAsString);
|
||||
return userUseCases.findById(userId)
|
||||
.map(CustomUserDetails::new)
|
||||
.orElseThrow(() -> new UsernameNotFoundException(userIdAsString));
|
||||
}
|
||||
|
||||
private UUID parseUserId(String userIdAsString) {
|
||||
try {
|
||||
return UUID.fromString(userIdAsString);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new UsernameNotFoundException(userIdAsString);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.codiki.application.security;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.codiki.domain.user.model.builder.UserBuilder.anUser;
|
||||
import org.codiki.domain.user.model.User;
|
||||
import org.codiki.domain.user.model.UserRole;
|
||||
import org.codiki.domain.user.model.builder.UserBuilder;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.auth0.jwt.JWT;
|
||||
import com.auth0.jwt.JWTVerifier;
|
||||
import com.auth0.jwt.algorithms.Algorithm;
|
||||
import com.auth0.jwt.exceptions.JWTVerificationException;
|
||||
import com.auth0.jwt.interfaces.Claim;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
private final Algorithm algorithm;
|
||||
private final JWTVerifier jwtVerifier;
|
||||
private final int tokenExpirationDelayInMinutes;
|
||||
|
||||
public JwtService(
|
||||
@Value("${application.security.jwt.secretKey}") String secretKey,
|
||||
@Value("${application.security.jwt.expirationDelayInMinutes}") int tokenExpirationDelayInMinutes
|
||||
) {
|
||||
algorithm = Algorithm.HMAC512(secretKey);
|
||||
this.tokenExpirationDelayInMinutes = tokenExpirationDelayInMinutes;
|
||||
jwtVerifier = JWT.require(algorithm).build();
|
||||
}
|
||||
|
||||
public String createJwt(User user) {
|
||||
ZonedDateTime expirationDate = ZonedDateTime.now().plusMinutes(tokenExpirationDelayInMinutes);
|
||||
|
||||
return JWT.create()
|
||||
.withSubject(user.id().toString())
|
||||
.withExpiresAt(expirationDate.toInstant())
|
||||
.withPayload(user.toJwtPayload())
|
||||
.sign(algorithm);
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
boolean result;
|
||||
try {
|
||||
jwtVerifier.verify(token);
|
||||
result = true;
|
||||
} catch (JWTVerificationException exception) {
|
||||
result = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public Optional<User> extractUser(String token) {
|
||||
Map<String, Claim> claims = JWT.decode(token).getClaims();
|
||||
|
||||
UserBuilder userBuilder = anUser()
|
||||
.withPassword("****");
|
||||
|
||||
Optional.ofNullable(claims.get("sub"))
|
||||
.map(Claim::asString)
|
||||
.map(this::mapUuid)
|
||||
.ifPresent(userBuilder::withId);
|
||||
|
||||
Optional.ofNullable(claims.get("pseudo"))
|
||||
.map(Claim::asString)
|
||||
.ifPresent(userBuilder::withPseudo);
|
||||
|
||||
Optional.ofNullable(claims.get("email"))
|
||||
.map(Claim::asString)
|
||||
.ifPresent(userBuilder::withEmail);
|
||||
|
||||
Optional.ofNullable(claims.get("photoId"))
|
||||
.map(Claim::asString)
|
||||
.map(this::mapUuid)
|
||||
.ifPresent(userBuilder::withPhotoId);
|
||||
|
||||
extractRoles(claims)
|
||||
.stream()
|
||||
.flatMap(Collection::stream)
|
||||
.map(UserRole::from)
|
||||
.flatMap(Optional::stream)
|
||||
.forEach(userBuilder::withRole);
|
||||
|
||||
return Optional.of(userBuilder.build());
|
||||
}
|
||||
|
||||
private static Optional<List<String>> extractRoles(Map<String, Claim> claims) {
|
||||
return Optional.ofNullable(claims.get("roles"))
|
||||
.map(Claim::asString)
|
||||
.map(roles -> roles.split(","))
|
||||
.map(Arrays::asList);
|
||||
}
|
||||
|
||||
private UUID mapUuid(String uuidAsString) {
|
||||
UUID result;
|
||||
try {
|
||||
result = UUID.fromString(uuidAsString);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
result = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.codiki.application.security.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@PreAuthorize("hasAuthority('ROLE_' + T(org.codiki.domain.user.model.UserRole).ADMIN.name())")
|
||||
public @interface AllowedToAdmins {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.codiki.application.security.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@PreAuthorize("permitAll()")
|
||||
public @interface AllowedToAnonymous {
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.codiki.application.security.model;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import org.codiki.domain.user.model.User;
|
||||
import org.codiki.domain.user.model.UserRole;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
public class CustomUserDetails implements UserDetails {
|
||||
private final User user;
|
||||
|
||||
public CustomUserDetails(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return user.roles()
|
||||
.stream()
|
||||
.map(UserRole::name)
|
||||
.map(role -> "ROLE_" + role)
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return user.id().toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return user.password();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.codiki.application.user;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.codiki.domain.user.model.UserRole.STANDARD;
|
||||
import static org.codiki.domain.user.model.builder.UserBuilder.anUser;
|
||||
import static org.springframework.util.ObjectUtils.isEmpty;
|
||||
import org.codiki.application.security.AuthenticationFacade;
|
||||
import org.codiki.application.security.JwtService;
|
||||
import org.codiki.application.security.annotation.AllowedToAdmins;
|
||||
import org.codiki.application.security.model.CustomUserDetails;
|
||||
import org.codiki.domain.exception.LoginFailureException;
|
||||
import org.codiki.domain.exception.RefreshTokenDoesNotExistException;
|
||||
import org.codiki.domain.exception.UserDoesNotExistException;
|
||||
import org.codiki.domain.user.exception.UserAlreadyExistsException;
|
||||
import org.codiki.domain.user.exception.UserCreationException;
|
||||
import org.codiki.domain.user.model.RefreshToken;
|
||||
import org.codiki.domain.user.model.User;
|
||||
import org.codiki.domain.user.model.UserAuthenticationData;
|
||||
import org.codiki.domain.user.port.UserPort;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class UserUseCases {
|
||||
private static final String TOKEN_TYPE = "Bearer";
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final UserPort userPort;
|
||||
private final AuthenticationFacade authenticationFacade;
|
||||
private final int refreshTokenExpirationDelayInDays;
|
||||
|
||||
public UserUseCases(
|
||||
AuthenticationFacade authenticationFacade,
|
||||
JwtService jwtService,
|
||||
PasswordEncoder passwordEncoder,
|
||||
UserPort userPort,
|
||||
@Value("${application.security.refreshToken.expirationDelayInDays}")
|
||||
int refreshTokenExpirationDelayInDays
|
||||
) {
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.userPort = userPort;
|
||||
this.authenticationFacade = authenticationFacade;
|
||||
this.refreshTokenExpirationDelayInDays = refreshTokenExpirationDelayInDays;
|
||||
}
|
||||
|
||||
public Optional<User> findById(UUID userId) {
|
||||
return userPort.findById(userId);
|
||||
}
|
||||
|
||||
@AllowedToAdmins
|
||||
public List<User> findAll() {
|
||||
return userPort.findAll();
|
||||
}
|
||||
|
||||
public UserAuthenticationData authenticate(String userEmail, String password) {
|
||||
User user = userPort.findByEmail(userEmail)
|
||||
.orElseThrow(LoginFailureException::new);
|
||||
|
||||
if (!passwordEncoder.matches(password, user.password())) {
|
||||
throw new LoginFailureException();
|
||||
}
|
||||
|
||||
return generateAuthenticationData(user);
|
||||
}
|
||||
|
||||
public UserAuthenticationData authenticate(UUID refreshTokenValue) {
|
||||
RefreshToken refreshToken = userPort.findRefreshTokenById(refreshTokenValue)
|
||||
.filter(RefreshToken::isNotExpired)
|
||||
.orElseThrow(() -> new RefreshTokenDoesNotExistException(refreshTokenValue));
|
||||
|
||||
User user = userPort.findById(refreshToken.userId())
|
||||
.orElseThrow(() -> new UserDoesNotExistException(refreshToken.userId()));
|
||||
|
||||
return generateAuthenticationData(user);
|
||||
}
|
||||
|
||||
public Optional<User> getAuthenticatedUser() {
|
||||
return Optional.of(authenticationFacade.getAuthentication())
|
||||
.map(Authentication::getPrincipal)
|
||||
.filter(CustomUserDetails.class::isInstance)
|
||||
.map(CustomUserDetails.class::cast)
|
||||
.map(CustomUserDetails::getUsername)
|
||||
.map(UUID::fromString)
|
||||
.flatMap(userPort::findById);
|
||||
}
|
||||
|
||||
private UserAuthenticationData generateAuthenticationData(User user) {
|
||||
String accessToken = jwtService.createJwt(user);
|
||||
|
||||
RefreshToken newRefreshToken = createNewRefreshToken(user);
|
||||
|
||||
return new UserAuthenticationData(
|
||||
TOKEN_TYPE,
|
||||
accessToken,
|
||||
newRefreshToken
|
||||
);
|
||||
}
|
||||
|
||||
private RefreshToken createNewRefreshToken(User user) {
|
||||
RefreshToken refreshToken = new RefreshToken(
|
||||
user.id(),
|
||||
ZonedDateTime.now().plusDays(refreshTokenExpirationDelayInDays)
|
||||
);
|
||||
userPort.save(refreshToken);
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public User createUser(String pseudo, String email, String password) {
|
||||
if (isEmpty(pseudo) || isEmpty(email) || isEmpty(password)) {
|
||||
throw new UserCreationException();
|
||||
}
|
||||
|
||||
if (userPort.existsByEmail(email)) {
|
||||
throw new UserAlreadyExistsException();
|
||||
}
|
||||
|
||||
User newUser = anUser()
|
||||
.withId(UUID.randomUUID())
|
||||
.withPseudo(pseudo)
|
||||
.withEmail(email)
|
||||
.withPassword(passwordEncoder.encode(password))
|
||||
.withRole(STANDARD)
|
||||
.build();
|
||||
|
||||
userPort.save(newUser);
|
||||
|
||||
return newUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.codiki.application.publication;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class KeyGeneratorTest {
|
||||
private KeyGenerator generator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
generator = new KeyGenerator();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generateKey_should_generate_random_keys_with_alphanumeric_characters() {
|
||||
Pattern validationRegex = Pattern.compile("^[0-9A-Z]{10}$");
|
||||
|
||||
IntStream.range(0, 1000)
|
||||
.forEach(index -> {
|
||||
String result = generator.generateKey();
|
||||
assertThat(validationRegex.matcher(result).matches()).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.codiki.application.publication;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
|
||||
import static org.codiki.domain.publication.model.search.ComparisonType.EQUALS;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.*;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
class PublicationSearchCriteriaFactoryTest {
|
||||
private PublicationSearchCriteriaFactory factory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
factory = new PublicationSearchCriteriaFactory();
|
||||
}
|
||||
|
||||
@Nested
|
||||
public class BuildCriteria {
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("arguments_of_should_build_criteria_from_search_query")
|
||||
void should_build_criteria_from_search_query(String searchQuery, List<PublicationSearchCriterion> expectedResult) {
|
||||
// when
|
||||
List<PublicationSearchCriterion> result = factory.buildCriteria(searchQuery);
|
||||
|
||||
// then
|
||||
assertThat(result).isEqualTo(expectedResult);
|
||||
}
|
||||
|
||||
private static Stream<Arguments> arguments_of_should_build_criteria_from_search_query() {
|
||||
return Stream.of(
|
||||
Arguments.of(
|
||||
"criterion",
|
||||
List.of(
|
||||
new PublicationSearchCriterion(KEY, CONTAINS, "criterion"),
|
||||
new PublicationSearchCriterion(TITLE, CONTAINS, "criterion"),
|
||||
new PublicationSearchCriterion(TEXT, CONTAINS, "criterion"),
|
||||
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "criterion"),
|
||||
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "criterion")
|
||||
)
|
||||
),
|
||||
Arguments.of(
|
||||
"key=value=crap",
|
||||
List.of(
|
||||
new PublicationSearchCriterion(KEY, CONTAINS, "key=value=crap"),
|
||||
new PublicationSearchCriterion(TITLE, CONTAINS, "key=value=crap"),
|
||||
new PublicationSearchCriterion(TEXT, CONTAINS, "key=value=crap"),
|
||||
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "key=value=crap"),
|
||||
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "key=value=crap")
|
||||
)
|
||||
),
|
||||
Arguments.of(
|
||||
"key=abcd",
|
||||
List.of(new PublicationSearchCriterion(KEY, EQUALS, "abcd"))
|
||||
),
|
||||
Arguments.of(
|
||||
"crappyFieldName=abcd",
|
||||
List.of(
|
||||
new PublicationSearchCriterion(KEY, CONTAINS, "crappyFieldName=abcd"),
|
||||
new PublicationSearchCriterion(TITLE, CONTAINS, "crappyFieldName=abcd"),
|
||||
new PublicationSearchCriterion(TEXT, CONTAINS, "crappyFieldName=abcd"),
|
||||
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "crappyFieldName=abcd"),
|
||||
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "crappyFieldName=abcd")
|
||||
)
|
||||
),
|
||||
Arguments.of(
|
||||
"id=abcd",
|
||||
List.of(
|
||||
new PublicationSearchCriterion(KEY, CONTAINS, "id=abcd"),
|
||||
new PublicationSearchCriterion(TITLE, CONTAINS, "id=abcd"),
|
||||
new PublicationSearchCriterion(TEXT, CONTAINS, "id=abcd"),
|
||||
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "id=abcd"),
|
||||
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "id=abcd")
|
||||
)
|
||||
),
|
||||
Arguments.of(
|
||||
"id=4faf591a-3986-465d-a6ec-538808a0129e",
|
||||
List.of(new PublicationSearchCriterion(ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
|
||||
),
|
||||
Arguments.of(
|
||||
"category_id=4faf591a-3986-465d-a6ec-538808a0129e",
|
||||
List.of(new PublicationSearchCriterion(CATEGORY_ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
|
||||
),
|
||||
Arguments.of(
|
||||
"author_id=4faf591a-3986-465d-a6ec-538808a0129e",
|
||||
List.of(new PublicationSearchCriterion(AUTHOR_ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
public class SplitAndSanitizeSearchCriterion {
|
||||
@Test
|
||||
void should_split_criteria_and_remove_duplicates() {
|
||||
// given
|
||||
String searchQuery = "criterion1 criterion2 criterion1";
|
||||
|
||||
// when
|
||||
Set<String> result = factory.splitAndSanitizeSearchCriterion(searchQuery);
|
||||
|
||||
// then
|
||||
assertThat(result).containsExactlyInAnyOrder("criterion1", "criterion2");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("arguments_of_should_remove_accents_and_split_criteria")
|
||||
void should_remove_accents_and_split_criteria(String searchQuery, Set<String> expectedResult) {
|
||||
// when
|
||||
Set<String> result = factory.splitAndSanitizeSearchCriterion(searchQuery);
|
||||
|
||||
// then
|
||||
assertThat(result).containsExactlyInAnyOrderElementsOf(expectedResult);
|
||||
}
|
||||
|
||||
private static Stream<Arguments> arguments_of_should_remove_accents_and_split_criteria() {
|
||||
return Stream.of(
|
||||
Arguments.of("critère", Set.of("crit", "re")),
|
||||
Arguments.of("recherchés", Set.of("recherch")),
|
||||
Arguments.of("abcdéfghîjklmnöp", Set.of("abcd", "fgh", "jklmn")),
|
||||
Arguments.of("ædf", Set.of("df"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user