Move backend files into a sub folder.
This commit is contained in:
52
backend/codiki-application/pom.xml
Normal file
52
backend/codiki-application/pom.xml
Normal file
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.codiki</groupId>
|
||||
<artifactId>codiki-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<artifactId>codiki-application</artifactId>
|
||||
|
||||
<name>codiki-application</name>
|
||||
<description>Demo project for Spring Boot</description>
|
||||
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.codiki</groupId>
|
||||
<artifactId>codiki-domain</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.auth0</groupId>
|
||||
<artifactId>java-jwt</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-params</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
backend/codiki-domain/pom.xml
Normal file
30
backend/codiki-domain/pom.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.codiki</groupId>
|
||||
<artifactId>codiki-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<artifactId>codiki-domain</artifactId>
|
||||
|
||||
<name>codiki-domain</name>
|
||||
<description>Demo project for Spring Boot</description>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<source>16</source>
|
||||
<target>16</target>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<packaging>jar</packaging>
|
||||
</project>
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.codiki.domain.category.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class CategoryDeletionException extends FunctionnalException {
|
||||
public CategoryDeletionException(UUID categoryId, String cause) {
|
||||
super(String.format("Impossible to delete category with id %s. Cause: %s.", categoryId, cause));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.codiki.domain.category.exception;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class CategoryEditionException extends FunctionnalException {
|
||||
public CategoryEditionException(String reason) {
|
||||
super(String.format("Impossible to edit a category because : %s.", reason));
|
||||
}
|
||||
|
||||
public CategoryEditionException(FunctionnalException cause) {
|
||||
super("Impossible to edit a category due to a root cause.", cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.codiki.domain.category.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class CategoryNotFoundException extends FunctionnalException {
|
||||
public CategoryNotFoundException(UUID categoryId) {
|
||||
super(String.format("No any category found for id %s.",categoryId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.codiki.domain.category.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record Category(
|
||||
UUID id,
|
||||
String name,
|
||||
List<Category> subCategories
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.codiki.domain.category.model.builder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.category.model.Category;
|
||||
|
||||
public class CategoryBuilder {
|
||||
private UUID id;
|
||||
private String name;
|
||||
private List<Category> subCategories;
|
||||
|
||||
public static CategoryBuilder aCategory() {
|
||||
return new CategoryBuilder();
|
||||
}
|
||||
|
||||
private CategoryBuilder() {}
|
||||
|
||||
public CategoryBuilder basedOn(Category category) {
|
||||
this.id = category.id();
|
||||
this.name = category.name();
|
||||
this.subCategories = category.subCategories();
|
||||
return this;
|
||||
}
|
||||
|
||||
public CategoryBuilder withId(UUID id) {
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CategoryBuilder withName(String name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CategoryBuilder withSubCategories(List<Category> subCategories) {
|
||||
this.subCategories = subCategories;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CategoryBuilder withSubCategory(Category subCategory) {
|
||||
if (subCategories == null) {
|
||||
subCategories = new ArrayList<>();
|
||||
}
|
||||
subCategories.add(subCategory);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Category build() {
|
||||
return new Category(id, name, subCategories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.codiki.domain.category.port;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.category.model.Category;
|
||||
|
||||
public interface CategoryPort {
|
||||
Optional<Category> findById(UUID uuid);
|
||||
|
||||
void save(Category category);
|
||||
|
||||
List<Category> findAllByIds(List<UUID> subCategoryIds);
|
||||
|
||||
boolean existsAnyAssociatedPublication(UUID categoryId);
|
||||
|
||||
void deleteById(UUID categoryId);
|
||||
|
||||
boolean existsById(UUID categoryId);
|
||||
|
||||
List<Category> findAll();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.codiki.domain.exception;
|
||||
|
||||
public class AuthenticationRequiredException extends FunctionnalException {
|
||||
public AuthenticationRequiredException() {
|
||||
super("Authentication is required to perform this action.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.codiki.domain.exception;
|
||||
|
||||
public abstract class FunctionnalException extends RuntimeException {
|
||||
public FunctionnalException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public FunctionnalException(FunctionnalException cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public FunctionnalException(String message, FunctionnalException cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.codiki.domain.exception;
|
||||
|
||||
public class LoginFailureException extends FunctionnalException {
|
||||
public LoginFailureException() {
|
||||
super("Login or password incorrect.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.domain.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class RefreshTokenDoesNotExistException extends FunctionnalException {
|
||||
public RefreshTokenDoesNotExistException(UUID refreshTokenValue) {
|
||||
super(String.format("Refresh token \"%s\" does not exist.", refreshTokenValue));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.domain.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class RefreshTokenExpiredException extends FunctionnalException {
|
||||
public RefreshTokenExpiredException(UUID refreshTokenValue) {
|
||||
super(String.format("Refresh token \"%s\" is expired.", refreshTokenValue));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.domain.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class UserDoesNotExistException extends FunctionnalException {
|
||||
public UserDoesNotExistException(UUID userId) {
|
||||
super(String.format("User \"%s\" does not exist.", userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.codiki.domain.picture.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class PictureContentLoadingErrorException extends FunctionnalException {
|
||||
public PictureContentLoadingErrorException(UUID pictureId) {
|
||||
super(String.format("An error occurred while loading picture content (picture id=%s).", pictureId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.codiki.domain.picture.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class PictureNotFoundException extends FunctionnalException {
|
||||
public PictureNotFoundException(UUID pictureId) {
|
||||
super(String.format("Picture with id %s is not found.", pictureId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.domain.picture.exception;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class PictureStorageErrorException extends FunctionnalException {
|
||||
public PictureStorageErrorException() {
|
||||
super("An error occurred while storing picture content file.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.domain.picture.exception;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class PictureUploadException extends FunctionnalException {
|
||||
public PictureUploadException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.codiki.domain.picture.model;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.UUID;
|
||||
|
||||
public record Picture(
|
||||
UUID id,
|
||||
UUID publisherId,
|
||||
File contentFile
|
||||
) {}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.codiki.domain.picture.model.builder;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.picture.model.Picture;
|
||||
import org.codiki.domain.user.model.User;
|
||||
|
||||
public class PictureBuilder {
|
||||
private UUID id;
|
||||
private UUID publisherId;
|
||||
private File contentFile;
|
||||
|
||||
private PictureBuilder() {}
|
||||
|
||||
public static PictureBuilder aPicture() {
|
||||
return new PictureBuilder();
|
||||
}
|
||||
|
||||
public PictureBuilder basedOn(Picture picture) {
|
||||
id = picture.id();
|
||||
contentFile = picture.contentFile();
|
||||
return this;
|
||||
}
|
||||
|
||||
public PictureBuilder withId(UUID id) {
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PictureBuilder withPublisherId(UUID publisherId) {
|
||||
this.publisherId = publisherId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PictureBuilder withPublisher(User publisher) {
|
||||
return withPublisherId(publisher.id());
|
||||
}
|
||||
|
||||
public PictureBuilder withContentFile(File contentFile) {
|
||||
this.contentFile = contentFile;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Picture build() {
|
||||
return new Picture(id, publisherId, contentFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.codiki.domain.picture.port;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.picture.model.Picture;
|
||||
|
||||
public interface PicturePort {
|
||||
boolean existsById(UUID pictureId);
|
||||
|
||||
Optional<Picture> findById(UUID pictureId);
|
||||
|
||||
void save(Picture picture);
|
||||
|
||||
void deleteById(UUID pictureId);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.domain.publication.exception;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class NoPublicationSearchResultException extends FunctionnalException {
|
||||
public NoPublicationSearchResultException() {
|
||||
super("No any publication was found for search criteria.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.codiki.domain.publication.exception;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class PublicationEditionException extends FunctionnalException {
|
||||
public PublicationEditionException(String reason) {
|
||||
super(String.format("Impossible to edit a publication because : %s.", reason));
|
||||
}
|
||||
|
||||
public PublicationEditionException(FunctionnalException cause) {
|
||||
super(String.format("Impossible to edit a publication due to a root cause: %s.", cause.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.codiki.domain.publication.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class PublicationNotFoundException extends FunctionnalException {
|
||||
public PublicationNotFoundException(UUID publicationId) {
|
||||
super(String.format("No any publication found for id %s.", publicationId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.domain.publication.exception;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class PublicationUpdateForbiddenException extends FunctionnalException {
|
||||
public PublicationUpdateForbiddenException() {
|
||||
super("Publication update is not allowed because you are not its owner.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.codiki.domain.publication.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record Author(
|
||||
UUID id,
|
||||
String name,
|
||||
String image
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.codiki.domain.publication.model;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record Publication(
|
||||
UUID id,
|
||||
String key,
|
||||
String title,
|
||||
String text,
|
||||
String description,
|
||||
ZonedDateTime creationDate,
|
||||
UUID illustrationId,
|
||||
UUID categoryId,
|
||||
Author author
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.codiki.domain.publication.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PublicationEditionRequest(
|
||||
String title,
|
||||
String text,
|
||||
String description,
|
||||
UUID illustrationId,
|
||||
UUID categoryId
|
||||
) {}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.codiki.domain.publication.model.builder;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.publication.model.Author;
|
||||
import org.codiki.domain.user.model.User;
|
||||
|
||||
public class AuthorBuilder {
|
||||
private UUID id;
|
||||
private String name;
|
||||
private String image;
|
||||
|
||||
private AuthorBuilder() {}
|
||||
|
||||
public static AuthorBuilder anAuthor() {
|
||||
return new AuthorBuilder();
|
||||
}
|
||||
|
||||
public AuthorBuilder basedOn(User user) {
|
||||
return new AuthorBuilder()
|
||||
.withId(user.id())
|
||||
// .withName(user.name())
|
||||
// .withImage(user.illustrationId())
|
||||
;
|
||||
}
|
||||
|
||||
public AuthorBuilder withId(UUID id) {
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AuthorBuilder withName(String name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AuthorBuilder withImage(String image) {
|
||||
this.image = image;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Author build() {
|
||||
//
|
||||
return new Author(id, name, image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.codiki.domain.publication.model.builder;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.category.model.Category;
|
||||
import org.codiki.domain.publication.model.Author;
|
||||
import org.codiki.domain.publication.model.Publication;
|
||||
|
||||
public class PublicationBuilder {
|
||||
private UUID id;
|
||||
private String key;
|
||||
private String title;
|
||||
private String text;
|
||||
private String description;
|
||||
private ZonedDateTime creationDate;
|
||||
private UUID illustrationId;
|
||||
private UUID categoryId;
|
||||
private Author author;
|
||||
|
||||
private PublicationBuilder() {}
|
||||
|
||||
public static PublicationBuilder aPublication() {
|
||||
return new PublicationBuilder();
|
||||
}
|
||||
|
||||
public PublicationBuilder basedOn(Publication publication) {
|
||||
return new PublicationBuilder()
|
||||
.withId(publication.id())
|
||||
.withKey(publication.key())
|
||||
.withTitle(publication.title())
|
||||
.withText(publication.text())
|
||||
.withDescription(publication.description())
|
||||
.withCreationDate(publication.creationDate())
|
||||
.withIllustrationId(publication.illustrationId())
|
||||
.withCategoryId(publication.categoryId())
|
||||
.withAuthor(publication.author());
|
||||
}
|
||||
|
||||
public PublicationBuilder withId(UUID id) {
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PublicationBuilder withKey(String key) {
|
||||
this.key = key;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PublicationBuilder withTitle(String title) {
|
||||
this.title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PublicationBuilder withText(String text) {
|
||||
this.text = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PublicationBuilder withDescription(String description) {
|
||||
this.description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PublicationBuilder withCreationDate(ZonedDateTime creationDate) {
|
||||
this.creationDate = creationDate;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PublicationBuilder withIllustrationId(UUID illustrationId) {
|
||||
this.illustrationId = illustrationId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PublicationBuilder withCategoryId(UUID categoryId) {
|
||||
this.categoryId = categoryId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PublicationBuilder withAuthor(Author author) {
|
||||
this.author = author;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Publication build() {
|
||||
return new Publication(
|
||||
id,
|
||||
key,
|
||||
title,
|
||||
text,
|
||||
description,
|
||||
creationDate,
|
||||
illustrationId,
|
||||
categoryId,
|
||||
author
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.codiki.domain.publication.model.search;
|
||||
|
||||
public enum ComparisonType {
|
||||
EQUALS,
|
||||
CONTAINS,
|
||||
BEFORE,
|
||||
AFTER
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.codiki.domain.publication.model.search;
|
||||
|
||||
public record PublicationSearchCriterion(
|
||||
PublicationSearchField searchField,
|
||||
ComparisonType searchType,
|
||||
Object value
|
||||
) { }
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.codiki.domain.publication.model.search;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public enum PublicationSearchField {
|
||||
ID,
|
||||
KEY,
|
||||
TITLE,
|
||||
TEXT,
|
||||
DESCRIPTION,
|
||||
CREATION_DATE,
|
||||
CATEGORY_ID,
|
||||
AUTHOR_ID,
|
||||
AUTHOR_PSEUDO;
|
||||
|
||||
public static Optional<PublicationSearchField> from(String fieldName) {
|
||||
return Optional.ofNullable(fieldName)
|
||||
.map(String::toUpperCase)
|
||||
.flatMap(uppercaseFieldName ->
|
||||
Stream.of(PublicationSearchField.values())
|
||||
.filter(field -> field.name().equals(uppercaseFieldName))
|
||||
.findFirst()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.codiki.domain.publication.port;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.publication.model.Publication;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
|
||||
public interface PublicationPort {
|
||||
void save(Publication publication);
|
||||
|
||||
Optional<Publication> findById(UUID publicationId);
|
||||
|
||||
void delete(Publication publication);
|
||||
|
||||
List<Publication> search(List<PublicationSearchCriterion> criteria);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.domain.user.exception;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class UserAlreadyExistsException extends FunctionnalException {
|
||||
public UserAlreadyExistsException() {
|
||||
super("An user already exists with this email address.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.domain.user.exception;
|
||||
|
||||
import org.codiki.domain.exception.FunctionnalException;
|
||||
|
||||
public class UserCreationException extends FunctionnalException {
|
||||
public UserCreationException() {
|
||||
super("Pseudo, email address and password can not be empty.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.codiki.domain.user.model;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RefreshToken(
|
||||
UUID userId,
|
||||
UUID value,
|
||||
ZonedDateTime expirationDate
|
||||
) {
|
||||
public RefreshToken(UUID userId, ZonedDateTime exporationDate) {
|
||||
this(userId, UUID.randomUUID(), exporationDate);
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return ZonedDateTime.now().isAfter(expirationDate);
|
||||
}
|
||||
|
||||
public boolean isNotExpired() {
|
||||
return !isExpired();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.codiki.domain.user.model;
|
||||
|
||||
import static java.util.Objects.isNull;
|
||||
import static java.util.stream.Collectors.joining;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record User(
|
||||
UUID id,
|
||||
String pseudo,
|
||||
String email,
|
||||
String password,
|
||||
UUID photoId,
|
||||
List<UserRole> roles
|
||||
) {
|
||||
public Map<String, Object> toJwtPayload() {
|
||||
Map<String, Object> result = new HashMap<>(4);
|
||||
|
||||
result.put("pseudo", pseudo);
|
||||
result.put("email", email);
|
||||
if (!isNull(photoId)) {
|
||||
result.put("photoId", photoId.toString());
|
||||
}
|
||||
|
||||
String rolesAsString = roles.stream()
|
||||
.map(UserRole::name)
|
||||
.collect(joining(","));
|
||||
result.put("roles", rolesAsString);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.codiki.domain.user.model;
|
||||
|
||||
public record UserAuthenticationData(
|
||||
String tokenType,
|
||||
String accessToken,
|
||||
RefreshToken refreshToken
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.codiki.domain.user.model;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public enum UserRole {
|
||||
STANDARD,
|
||||
ADMIN;
|
||||
|
||||
public static Optional<UserRole> from(String roleAsString) {
|
||||
return Stream.of(UserRole.values())
|
||||
.filter(role -> role.name().equals(roleAsString))
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.codiki.domain.user.model.builder;
|
||||
|
||||
import static java.util.Objects.isNull;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.user.exception.UserCreationException;
|
||||
import org.codiki.domain.user.model.User;
|
||||
import org.codiki.domain.user.model.UserRole;
|
||||
|
||||
public class UserBuilder {
|
||||
private UUID id;
|
||||
private String pseudo;
|
||||
private String email;
|
||||
private String password;
|
||||
private UUID photoId;
|
||||
private Set<UserRole> roles = new HashSet<>();
|
||||
|
||||
private UserBuilder() {}
|
||||
|
||||
public static UserBuilder anUser() {
|
||||
return new UserBuilder();
|
||||
}
|
||||
|
||||
public UserBuilder withId(UUID id) {
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public UserBuilder withPseudo(String pseudo) {
|
||||
this.pseudo = pseudo;
|
||||
return this;
|
||||
}
|
||||
|
||||
public UserBuilder withEmail(String email) {
|
||||
this.email = email;
|
||||
return this;
|
||||
}
|
||||
|
||||
public UserBuilder withPassword(String password) {
|
||||
this.password = password;
|
||||
return this;
|
||||
}
|
||||
|
||||
public UserBuilder withPhotoId(UUID photoId) {
|
||||
this.photoId = photoId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public UserBuilder withRole(UserRole role) {
|
||||
this.roles.add(role);
|
||||
return this;
|
||||
}
|
||||
|
||||
public UserBuilder withRoles(List<UserRole> roles) {
|
||||
this.roles = new HashSet<>(roles);
|
||||
return this;
|
||||
}
|
||||
|
||||
public User build() {
|
||||
if (isNull(id) || isNull(pseudo) || isNull(email) || isNull(password) || isEmpty(roles)) {
|
||||
throw new UserCreationException();
|
||||
}
|
||||
|
||||
return new User(id, pseudo, email, password, photoId, new LinkedList<>(roles));
|
||||
}
|
||||
|
||||
private static boolean isEmpty(Set<UserRole> roles) {
|
||||
return isNull(roles) || roles.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.codiki.domain.user.port;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.user.model.RefreshToken;
|
||||
import org.codiki.domain.user.model.User;
|
||||
|
||||
public interface UserPort {
|
||||
Optional<User> findById(UUID userId);
|
||||
|
||||
Optional<User> findByEmail(String userEmail);
|
||||
|
||||
List<User> findAll();
|
||||
|
||||
void save(User user);
|
||||
|
||||
boolean existsById(UUID userId);
|
||||
|
||||
Optional<RefreshToken> findRefreshTokenByUserId(UUID userId);
|
||||
|
||||
Optional<RefreshToken> findRefreshTokenById(UUID refreshTokenId);
|
||||
|
||||
void save(RefreshToken refreshToken);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
60
backend/codiki-exposition/pom.xml
Normal file
60
backend/codiki-exposition/pom.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.codiki</groupId>
|
||||
<artifactId>codiki-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<artifactId>codiki-exposition</artifactId>
|
||||
|
||||
<name>codiki-exposition</name>
|
||||
<description>Demo project for Spring Boot</description>
|
||||
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.codiki</groupId>
|
||||
<artifactId>codiki-application</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-core</artifactId>
|
||||
</dependency>
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.boot</groupId>-->
|
||||
<!-- <artifactId>spring-boot-starter-data-jpa</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.boot</groupId>-->
|
||||
<!-- <artifactId>spring-boot-starter-security</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.postgresql</groupId>-->
|
||||
<!-- <artifactId>postgresql</artifactId>-->
|
||||
<!-- <scope>runtime</scope>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.boot</groupId>-->
|
||||
<!-- <artifactId>spring-boot-starter-test</artifactId>-->
|
||||
<!-- <scope>test</scope>-->
|
||||
<!-- </dependency>-->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.security</groupId>-->
|
||||
<!-- <artifactId>spring-security-test</artifactId>-->
|
||||
<!-- <scope>test</scope>-->
|
||||
<!-- </dependency>-->
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.codiki.exposition.category;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.HttpStatus.CREATED;
|
||||
import static org.springframework.http.HttpStatus.NO_CONTENT;
|
||||
import org.codiki.application.category.CategoryUseCases;
|
||||
import org.codiki.domain.category.model.Category;
|
||||
import org.codiki.exposition.category.model.CategoryDto;
|
||||
import org.codiki.exposition.category.model.CategoryEditionRequest;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/categories")
|
||||
public class CategoryController {
|
||||
private final CategoryUseCases categoryUseCases;
|
||||
|
||||
public CategoryController(CategoryUseCases categoryUseCases) {
|
||||
this.categoryUseCases = categoryUseCases;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(CREATED)
|
||||
public CategoryDto createCategory(@RequestBody CategoryEditionRequest request) {
|
||||
Category createdCategory = categoryUseCases.createCategory(request.name(), request.subCategoryIds());
|
||||
return new CategoryDto(createdCategory);
|
||||
}
|
||||
|
||||
@PutMapping("/{categoryId}")
|
||||
public CategoryDto updateCategory(
|
||||
@PathVariable("categoryId") UUID categoryId,
|
||||
@RequestBody CategoryEditionRequest request
|
||||
) {
|
||||
Category createdCategory = categoryUseCases.updateCategory(
|
||||
categoryId,
|
||||
request.name(),
|
||||
request.subCategoryIds()
|
||||
);
|
||||
return new CategoryDto(createdCategory);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{categoryId}")
|
||||
@ResponseStatus(NO_CONTENT)
|
||||
public void deleteCategory(@PathVariable("categoryId") UUID categoryId) {
|
||||
categoryUseCases.deleteCategory(categoryId);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<CategoryDto> getAllCategories() {
|
||||
return categoryUseCases.getAll()
|
||||
.stream()
|
||||
.map(CategoryDto::new)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.codiki.exposition.category.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.category.model.Category;
|
||||
|
||||
public record CategoryDto(
|
||||
UUID id,
|
||||
String name,
|
||||
List<CategoryDto> subCategories
|
||||
) {
|
||||
public CategoryDto(Category category) {
|
||||
this(
|
||||
category.id(),
|
||||
category.name(),
|
||||
category.subCategories()
|
||||
.stream()
|
||||
.map(CategoryDto::new)
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.exposition.category.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CategoryEditionRequest(
|
||||
String name,
|
||||
List<UUID> subCategoryIds
|
||||
) {}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.codiki.exposition.configuration;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||
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.exception.LoginFailureException;
|
||||
import org.codiki.domain.exception.RefreshTokenDoesNotExistException;
|
||||
import org.codiki.domain.exception.RefreshTokenExpiredException;
|
||||
import org.codiki.domain.exception.UserDoesNotExistException;
|
||||
import org.codiki.domain.picture.exception.PictureNotFoundException;
|
||||
import org.codiki.domain.picture.exception.PictureUploadException;
|
||||
import org.codiki.domain.publication.exception.NoPublicationSearchResultException;
|
||||
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.user.exception.UserAlreadyExistsException;
|
||||
import org.codiki.domain.user.exception.UserCreationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
@ExceptionHandler({
|
||||
CategoryDeletionException.class,
|
||||
CategoryEditionException.class,
|
||||
CategoryNotFoundException.class,
|
||||
LoginFailureException.class,
|
||||
PublicationEditionException.class,
|
||||
PictureUploadException.class,
|
||||
UserAlreadyExistsException.class,
|
||||
UserCreationException.class
|
||||
})
|
||||
public ProblemDetail handleBadRequestExceptions(Exception exception) {
|
||||
return buildProblemDetail(BAD_REQUEST, exception);
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
UserDoesNotExistException.class,
|
||||
RefreshTokenDoesNotExistException.class,
|
||||
PublicationNotFoundException.class,
|
||||
PictureNotFoundException.class,
|
||||
NoPublicationSearchResultException.class
|
||||
})
|
||||
public ProblemDetail handleNotFoundExceptions(Exception exception) {
|
||||
return buildProblemDetail(NOT_FOUND, exception);
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
RefreshTokenExpiredException.class
|
||||
})
|
||||
public ProblemDetail handleUnauthorizedExceptions(Exception exception) {
|
||||
return buildProblemDetail(UNAUTHORIZED, exception);
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
PublicationUpdateForbiddenException.class
|
||||
})
|
||||
public ProblemDetail handleForbiddenExceptions(Exception exception) {
|
||||
return buildProblemDetail(FORBIDDEN, exception);
|
||||
}
|
||||
|
||||
private static ProblemDetail buildProblemDetail(HttpStatus forbidden, Exception exception) {
|
||||
return ProblemDetail.forStatusAndDetail(forbidden, exception.getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.codiki.exposition.configuration.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
|
||||
import static org.springframework.util.ObjectUtils.isEmpty;
|
||||
import org.codiki.application.security.JwtService;
|
||||
import org.codiki.application.security.model.CustomUserDetails;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private static final String BEARER_PREFIX = "Bearer ";
|
||||
|
||||
private final JwtService jwtService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtService jwtService) {
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
Optional.ofNullable(request.getHeader(AUTHORIZATION))
|
||||
.filter(authorizationHeader -> !isEmpty(authorizationHeader))
|
||||
.filter(authorizationHeader -> authorizationHeader.startsWith(BEARER_PREFIX))
|
||||
.map(authorizationHeader -> authorizationHeader.substring(BEARER_PREFIX.length()))
|
||||
.filter(token -> {
|
||||
String authorizationHeader = request.getHeader(AUTHORIZATION);
|
||||
return !isEmpty(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX);
|
||||
})
|
||||
.filter(jwtService::isValid)
|
||||
.flatMap(jwtService::extractUser)
|
||||
.map(CustomUserDetails::new)
|
||||
.map(userDetails -> new UsernamePasswordAuthenticationToken(
|
||||
userDetails,
|
||||
null,
|
||||
userDetails.getAuthorities()
|
||||
))
|
||||
.ifPresent(authenticationToken -> {
|
||||
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
|
||||
});
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.codiki.exposition.configuration.security;
|
||||
|
||||
import static org.springframework.http.HttpMethod.DELETE;
|
||||
import static org.springframework.http.HttpMethod.GET;
|
||||
import static org.springframework.http.HttpMethod.OPTIONS;
|
||||
import static org.springframework.http.HttpMethod.POST;
|
||||
import static org.springframework.http.HttpMethod.PUT;
|
||||
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;
|
||||
import org.codiki.domain.user.model.UserRole;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
import static jakarta.servlet.DispatcherType.FORWARD;
|
||||
import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
|
||||
import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity(securedEnabled = true)
|
||||
public class SecurityConfiguration {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity httpSecurity,
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter
|
||||
) throws Exception {
|
||||
httpSecurity
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.exceptionHandling(configurer -> configurer
|
||||
.authenticationEntryPoint((request, response, authException) -> response.sendError(SC_UNAUTHORIZED))
|
||||
.accessDeniedHandler((request, response, accessDeniedException) -> response.sendError(SC_FORBIDDEN))
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.sessionManagement(customizer -> customizer.sessionCreationPolicy(STATELESS))
|
||||
.authorizeHttpRequests(requests -> requests
|
||||
.dispatcherTypeMatchers(FORWARD).permitAll()
|
||||
.requestMatchers(
|
||||
GET,
|
||||
"/api/health/check",
|
||||
"/api/categories",
|
||||
"/api/pictures/{pictureId}",
|
||||
"/api/publications/{publicationId}",
|
||||
"/api/publications",
|
||||
"/error"
|
||||
).permitAll()
|
||||
.requestMatchers(
|
||||
POST,
|
||||
"/api/users/login",
|
||||
"/api/users/refresh-token"
|
||||
).permitAll()
|
||||
.requestMatchers(
|
||||
POST,
|
||||
"/api/categories"
|
||||
).hasRole(UserRole.ADMIN.name())
|
||||
.requestMatchers(
|
||||
PUT,
|
||||
"/api/categories/{categoryId}"
|
||||
).hasRole(UserRole.ADMIN.name())
|
||||
.requestMatchers(
|
||||
DELETE,
|
||||
"/api/categories/{categoryId}"
|
||||
).hasRole(UserRole.ADMIN.name())
|
||||
.requestMatchers(OPTIONS).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
);
|
||||
|
||||
return httpSecurity.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.codiki.exposition.healthcheck;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/health")
|
||||
public class HealthCheckController {
|
||||
@GetMapping("/check")
|
||||
public String healthCheck() {
|
||||
return "Ok";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.codiki.exposition.picture;
|
||||
|
||||
import static java.util.Objects.isNull;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.tika.mime.MimeType;
|
||||
import org.apache.tika.mime.MimeTypeException;
|
||||
import org.apache.tika.mime.MimeTypes;
|
||||
import org.codiki.domain.picture.exception.PictureUploadException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Component
|
||||
public class MultipartFileConverter {
|
||||
private static final List<MimeType> ALLOWED_MIME_TYPES;
|
||||
|
||||
static {
|
||||
MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes();
|
||||
|
||||
try {
|
||||
ALLOWED_MIME_TYPES = List.of(
|
||||
mimeTypes.forName("image/png"),
|
||||
mimeTypes.forName("image/jpeg"),
|
||||
mimeTypes.forName("image/svg+xml")
|
||||
);
|
||||
} catch (MimeTypeException exception) {
|
||||
throw new RuntimeException("An error occurred while loading allowed mime types.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private final String tempPicturesFolderPath;
|
||||
|
||||
public MultipartFileConverter(@Value("${application.pictures.temp-path}") String tempPicturesFolderPath) {
|
||||
this.tempPicturesFolderPath = tempPicturesFolderPath;
|
||||
}
|
||||
|
||||
public File transformToFile(MultipartFile fileContent) {
|
||||
File pictureFile = new File(buildPicturePath(fileContent));
|
||||
try {
|
||||
fileContent.transferTo(pictureFile);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return pictureFile;
|
||||
}
|
||||
|
||||
private String buildPicturePath(MultipartFile fileContent) {
|
||||
checkMimeTypeIsAllowed(fileContent);
|
||||
return String.format(
|
||||
"%s/%s",
|
||||
tempPicturesFolderPath,
|
||||
UUID.randomUUID()
|
||||
);
|
||||
}
|
||||
|
||||
private void checkMimeTypeIsAllowed(MultipartFile fileContent) {
|
||||
MimeType result = null;
|
||||
try {
|
||||
result = MimeTypes.getDefaultMimeTypes()
|
||||
.forName(fileContent.getContentType());
|
||||
} catch (MimeTypeException exception) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
if (isNull(result) || !isAllowedMimeType(result)) {
|
||||
throw new PictureUploadException("Unable to upload the picture because its format is incorrect.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAllowedMimeType(MimeType mimeType) {
|
||||
return ALLOWED_MIME_TYPES.contains(mimeType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.codiki.exposition.picture;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE;
|
||||
import org.codiki.application.picture.PictureUseCases;
|
||||
import org.codiki.domain.picture.exception.PictureNotFoundException;
|
||||
import org.codiki.domain.picture.model.Picture;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/pictures")
|
||||
public class PictureController {
|
||||
private final MultipartFileConverter multipartFileConverter;
|
||||
private final PictureUseCases pictureUseCases;
|
||||
|
||||
public PictureController(
|
||||
MultipartFileConverter multipartFileConverter,
|
||||
PictureUseCases pictureUseCases
|
||||
) {
|
||||
this.multipartFileConverter = multipartFileConverter;
|
||||
this.pictureUseCases = pictureUseCases;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MULTIPART_FORM_DATA_VALUE)
|
||||
public UUID uploadPicture(@RequestParam("file") MultipartFile fileContent) {
|
||||
File pictureFile = multipartFileConverter.transformToFile(fileContent);
|
||||
Picture newPicture = pictureUseCases.createPicture(pictureFile);
|
||||
return newPicture.id();
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{pictureId}", produces = APPLICATION_OCTET_STREAM_VALUE)
|
||||
public FileSystemResource loadPicture(@PathVariable("pictureId") UUID pictureId) {
|
||||
Picture picture = pictureUseCases.findById(pictureId)
|
||||
.orElseThrow(() -> new PictureNotFoundException(pictureId));
|
||||
return new FileSystemResource(picture.contentFile());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.codiki.exposition.publication;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.http.HttpStatus.CREATED;
|
||||
import static org.springframework.http.HttpStatus.NO_CONTENT;
|
||||
import static org.springframework.util.ObjectUtils.isEmpty;
|
||||
import org.codiki.application.publication.PublicationUseCases;
|
||||
import org.codiki.domain.publication.exception.NoPublicationSearchResultException;
|
||||
import org.codiki.domain.publication.exception.PublicationNotFoundException;
|
||||
import org.codiki.domain.publication.model.Publication;
|
||||
import org.codiki.domain.publication.model.PublicationEditionRequest;
|
||||
import org.codiki.exposition.publication.model.PublicationDto;
|
||||
import org.codiki.exposition.publication.model.PublicationEditionRequestDto;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/publications")
|
||||
public class PublicationController {
|
||||
private final PublicationUseCases publicationUseCases;
|
||||
|
||||
public PublicationController(PublicationUseCases publicationUseCases) {
|
||||
this.publicationUseCases = publicationUseCases;
|
||||
}
|
||||
|
||||
@GetMapping("/{publicationId}")
|
||||
public PublicationDto getById(@PathVariable("publicationId") UUID publicationId) {
|
||||
return publicationUseCases.findById(publicationId)
|
||||
.map(PublicationDto::new)
|
||||
.orElseThrow(() -> new PublicationNotFoundException(publicationId));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(CREATED)
|
||||
public PublicationDto createPublication(@RequestBody PublicationEditionRequestDto requestDto) {
|
||||
PublicationEditionRequest request = requestDto.toDomain();
|
||||
Publication newPublication = publicationUseCases.createPublication(request);
|
||||
return new PublicationDto(newPublication);
|
||||
}
|
||||
|
||||
@PutMapping("/{publicationId}")
|
||||
public PublicationDto updatePublication(
|
||||
@PathVariable("publicationId") UUID publicationId,
|
||||
@RequestBody PublicationEditionRequestDto requestDto
|
||||
) {
|
||||
PublicationEditionRequest request = requestDto.toDomain();
|
||||
Publication updatedPublication = publicationUseCases.updatePublication(publicationId, request);
|
||||
return new PublicationDto(updatedPublication);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{publicationId}")
|
||||
@ResponseStatus(NO_CONTENT)
|
||||
public void deletePublication(@PathVariable("publicationId") UUID publicationId) {
|
||||
publicationUseCases.deletePublication(publicationId);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PublicationDto> searchPublications(@RequestParam("query") String searchQuery) {
|
||||
final List<PublicationDto> publications = publicationUseCases.searchPublications(searchQuery)
|
||||
.stream()
|
||||
.map(PublicationDto::new)
|
||||
.toList();
|
||||
|
||||
if (isEmpty(publications)) {
|
||||
throw new NoPublicationSearchResultException();
|
||||
}
|
||||
|
||||
return publications;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.codiki.exposition.publication.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.publication.model.Author;
|
||||
|
||||
public record AuthorDto(
|
||||
UUID id,
|
||||
String name,
|
||||
String image
|
||||
) {
|
||||
public AuthorDto(Author author) {
|
||||
this(
|
||||
author.id(),
|
||||
author.name(),
|
||||
author.image()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.codiki.exposition.publication.model;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.publication.model.Publication;
|
||||
import org.codiki.exposition.category.model.CategoryDto;
|
||||
|
||||
public record PublicationDto(
|
||||
UUID id,
|
||||
String key,
|
||||
String title,
|
||||
String text,
|
||||
String description,
|
||||
ZonedDateTime creationDate,
|
||||
UUID illustrationId,
|
||||
UUID categoryId,
|
||||
AuthorDto author
|
||||
) {
|
||||
public PublicationDto(Publication publication) {
|
||||
this(
|
||||
publication.id(),
|
||||
publication.key(),
|
||||
publication.title(),
|
||||
publication.text(),
|
||||
publication.description(),
|
||||
publication.creationDate(),
|
||||
publication.illustrationId(),
|
||||
publication.categoryId(),
|
||||
new AuthorDto(publication.author())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.codiki.exposition.publication.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.publication.model.PublicationEditionRequest;
|
||||
|
||||
public record PublicationEditionRequestDto(
|
||||
String title,
|
||||
String text,
|
||||
String description,
|
||||
UUID illustrationId,
|
||||
UUID categoryId
|
||||
) {
|
||||
public PublicationEditionRequest toDomain() {
|
||||
return new PublicationEditionRequest(title, text, description, illustrationId, categoryId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.codiki.exposition.user;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.springframework.http.HttpStatus.CREATED;
|
||||
import org.codiki.application.security.annotation.AllowedToAdmins;
|
||||
import org.codiki.application.security.annotation.AllowedToAnonymous;
|
||||
import org.codiki.application.user.UserUseCases;
|
||||
import org.codiki.domain.user.model.User;
|
||||
import org.codiki.domain.user.model.UserAuthenticationData;
|
||||
import org.codiki.exposition.user.model.LoginRequest;
|
||||
import org.codiki.exposition.user.model.LoginResponse;
|
||||
import org.codiki.exposition.user.model.RefreshTokenRequest;
|
||||
import org.codiki.exposition.user.model.SignInRequestDto;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
private final UserUseCases userUseCases;
|
||||
|
||||
public UserController(UserUseCases userUseCases) {
|
||||
this.userUseCases = userUseCases;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
@AllowedToAnonymous
|
||||
public LoginResponse login(@RequestBody LoginRequest request) {
|
||||
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.email(), request.password());
|
||||
return new LoginResponse(userAuthenticationData);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@AllowedToAdmins
|
||||
public List<User> findAll() {
|
||||
return userUseCases.findAll();
|
||||
}
|
||||
|
||||
@PostMapping("/refresh-token")
|
||||
public LoginResponse refreshToken(@RequestBody RefreshTokenRequest request) {
|
||||
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.refreshTokenValue());
|
||||
return new LoginResponse(userAuthenticationData);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(CREATED)
|
||||
public void signIn(@RequestBody SignInRequestDto request) {
|
||||
userUseCases.createUser(request.pseudo(), request.email(), request.password());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.codiki.exposition.user.model;
|
||||
|
||||
public record LoginRequest(
|
||||
String email,
|
||||
String password
|
||||
) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.codiki.exposition.user.model;
|
||||
|
||||
import org.codiki.domain.user.model.UserAuthenticationData;
|
||||
|
||||
public record LoginResponse(
|
||||
String tokenType,
|
||||
String accessToken,
|
||||
String refreshToken
|
||||
) {
|
||||
public LoginResponse(UserAuthenticationData userAuthenticationData) {
|
||||
this(
|
||||
userAuthenticationData.tokenType(),
|
||||
userAuthenticationData.accessToken(),
|
||||
userAuthenticationData.refreshToken().value().toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.codiki.exposition.user.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record RefreshTokenRequest(
|
||||
UUID refreshTokenValue
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.codiki.exposition.user.model;
|
||||
|
||||
public record SignInRequestDto(
|
||||
String pseudo,
|
||||
String email,
|
||||
String password
|
||||
) {
|
||||
}
|
||||
60
backend/codiki-infrastructure/pom.xml
Normal file
60
backend/codiki-infrastructure/pom.xml
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.codiki</groupId>
|
||||
<artifactId>codiki-parent</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<artifactId>codiki-infrastructure</artifactId>
|
||||
|
||||
<name>codiki-infrastructure</name>
|
||||
<description>Demo project for Spring Boot</description>
|
||||
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.codiki</groupId>
|
||||
<artifactId>codiki-domain</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-params</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.codiki.infrastructure.category;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.category.exception.CategoryNotFoundException;
|
||||
import org.codiki.domain.category.model.Category;
|
||||
import org.codiki.domain.category.port.CategoryPort;
|
||||
import org.codiki.infrastructure.category.model.CategoryEntity;
|
||||
import org.codiki.infrastructure.category.repository.CategoryRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class CategoryJpaAdapter implements CategoryPort {
|
||||
private final CategoryRepository categoryRepository;
|
||||
|
||||
public CategoryJpaAdapter(CategoryRepository categoryRepository) {
|
||||
this.categoryRepository = categoryRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Category> findById(UUID categoryId) {
|
||||
return categoryRepository.findById(categoryId)
|
||||
.map(CategoryEntity::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(Category category) {
|
||||
CategoryEntity categoryEntity = new CategoryEntity(category);
|
||||
categoryRepository.save(categoryEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Category> findAllByIds(List<UUID> categoryIds) {
|
||||
final List<Category> categories = categoryRepository.findAllById(categoryIds)
|
||||
.stream()
|
||||
.map(CategoryEntity::toDomain)
|
||||
.toList();
|
||||
|
||||
Optional<UUID> notFoundCategoryId = categoryIds.stream()
|
||||
.filter(categoryId -> categories.stream().map(Category::id).noneMatch(categoryId::equals))
|
||||
.findFirst();
|
||||
if (notFoundCategoryId.isPresent()) {
|
||||
throw new CategoryNotFoundException(notFoundCategoryId.get());
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsAnyAssociatedPublication(UUID categoryId) {
|
||||
return categoryRepository.existsAnyAssociatedPublication(categoryId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(UUID categoryId) {
|
||||
categoryRepository.deleteById(categoryId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(UUID categoryId) {
|
||||
return categoryRepository.existsById(categoryId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Category> findAll() {
|
||||
return categoryRepository.findAll()
|
||||
.stream()
|
||||
.map(CategoryEntity::toDomain)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.codiki.infrastructure.category.model;
|
||||
|
||||
import static java.util.Collections.emptyList;
|
||||
import static java.util.stream.Collectors.toSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.codiki.domain.category.model.Category;
|
||||
|
||||
import static jakarta.persistence.CascadeType.ALL;
|
||||
import static jakarta.persistence.FetchType.LAZY;
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "category")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CategoryEntity {
|
||||
@Id
|
||||
private UUID id;
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
@OneToMany
|
||||
@JoinColumn(name = "parent_category_id")
|
||||
private Set<CategoryEntity> subCategories;
|
||||
|
||||
public CategoryEntity(Category category) {
|
||||
this(
|
||||
category.id(),
|
||||
category.name(),
|
||||
category.subCategories()
|
||||
.stream()
|
||||
.map(CategoryEntity::new)
|
||||
.collect(toSet())
|
||||
);
|
||||
}
|
||||
|
||||
public Category toDomain() {
|
||||
return new Category(
|
||||
id,
|
||||
name,
|
||||
subCategories.stream()
|
||||
.map(CategoryEntity::toDomain)
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.codiki.infrastructure.category.repository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.infrastructure.category.model.CategoryEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface CategoryRepository extends JpaRepository<CategoryEntity, UUID> {
|
||||
@Query(value = """
|
||||
SELECT (
|
||||
SELECT COUNT(*)
|
||||
FROM publication p
|
||||
WHERE p.category_id = :categoryId
|
||||
) > 0
|
||||
""", nativeQuery = true)
|
||||
boolean existsAnyAssociatedPublication(@Param("categoryId") UUID categoryId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.codiki.infrastructure.configuration;
|
||||
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
@Configuration
|
||||
@EnableJpaRepositories("org.codiki.infrastructure")
|
||||
@EntityScan("org.codiki.infrastructure")
|
||||
public class JpaConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.codiki.infrastructure.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.domain.picture.exception.PictureNotFoundException;
|
||||
import org.codiki.domain.picture.exception.PictureStorageErrorException;
|
||||
import org.codiki.domain.picture.model.Picture;
|
||||
import org.codiki.domain.picture.port.PicturePort;
|
||||
import org.codiki.infrastructure.picture.model.PictureEntity;
|
||||
import org.codiki.infrastructure.picture.repository.PictureRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
@Component
|
||||
public class PictureJpaAdapter implements PicturePort {
|
||||
private final PictureRepository repository;
|
||||
private final String pictureFolderPath;
|
||||
|
||||
public PictureJpaAdapter(
|
||||
PictureRepository repository,
|
||||
@Value("${application.pictures.path}") String pictureFolderPath
|
||||
) {
|
||||
this.repository = repository;
|
||||
this.pictureFolderPath = pictureFolderPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(UUID pictureId) {
|
||||
return repository.existsById(pictureId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Picture> findById(UUID pictureId) {
|
||||
return repository.findById(pictureId)
|
||||
.map(PictureEntity::toDomain)
|
||||
.map(picture -> {
|
||||
File pictureFile = new File(String.format("%s/%s", pictureFolderPath, pictureId));
|
||||
if (!pictureFile.exists()) {
|
||||
throw new PictureNotFoundException(pictureId);
|
||||
}
|
||||
return aPicture()
|
||||
.basedOn(picture)
|
||||
.withContentFile(pictureFile)
|
||||
.build();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void save(Picture picture) {
|
||||
PictureEntity pictureEntity = new PictureEntity(picture);
|
||||
repository.save(pictureEntity);
|
||||
|
||||
boolean isMoved = picture.contentFile().renameTo(new File(String.format("%s/%s", pictureFolderPath, picture.id())));
|
||||
if (!isMoved) {
|
||||
throw new PictureStorageErrorException();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteById(UUID pictureId) {
|
||||
repository.deleteById(pictureId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.codiki.infrastructure.picture.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.picture.model.Picture;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "picture")
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class PictureEntity {
|
||||
@Id
|
||||
private UUID id;
|
||||
@Column(nullable = false)
|
||||
private UUID publisherId;
|
||||
|
||||
public PictureEntity(Picture picture) {
|
||||
id = picture.id();
|
||||
publisherId = picture.publisherId();
|
||||
}
|
||||
|
||||
public Picture toDomain() {
|
||||
return new Picture(id, publisherId, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.codiki.infrastructure.picture.repository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.infrastructure.picture.model.PictureEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PictureRepository extends JpaRepository<PictureEntity, UUID> {
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.codiki.infrastructure.publication;
|
||||
|
||||
import static java.util.Collections.reverseOrder;
|
||||
import static java.util.Comparator.comparingInt;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.publication.model.Publication;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
import org.codiki.domain.publication.port.PublicationPort;
|
||||
import org.codiki.infrastructure.publication.model.PublicationEntity;
|
||||
import org.codiki.infrastructure.publication.model.PublicationSearchResult;
|
||||
import org.codiki.infrastructure.publication.repository.PublicationRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PublicationJpaAdapter implements PublicationPort {
|
||||
private final PublicationRepository repository;
|
||||
private final PublicationSearchCriteriaJpaAdapter publicationSearchCriteriaJpaAdapter;
|
||||
|
||||
public PublicationJpaAdapter(
|
||||
PublicationRepository repository,
|
||||
PublicationSearchCriteriaJpaAdapter publicationSearchCriteriaJpaAdapter
|
||||
) {
|
||||
this.repository = repository;
|
||||
this.publicationSearchCriteriaJpaAdapter = publicationSearchCriteriaJpaAdapter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(Publication publication) {
|
||||
PublicationEntity newPublicationEntity = new PublicationEntity(publication);
|
||||
repository.save(newPublicationEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Publication> findById(UUID publicationId) {
|
||||
return repository.findById(publicationId)
|
||||
.map(PublicationEntity::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(Publication publication) {
|
||||
repository.deleteById(publication.id());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Publication> search(List<PublicationSearchCriterion> criteria) {
|
||||
List<PublicationSearchCriterion> adaptedCriteria = publicationSearchCriteriaJpaAdapter.adaptCriteriaForJpa(criteria);
|
||||
return repository.search(adaptedCriteria)
|
||||
.stream()
|
||||
.map(PublicationEntity::toDomain)
|
||||
.map(publication -> new PublicationSearchResult(publication, criteria))
|
||||
.sorted(reverseOrder(comparingInt(PublicationSearchResult::getSearchScore)))
|
||||
.map(PublicationSearchResult::getPublication)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.codiki.infrastructure.publication;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PublicationSearchCriteriaJpaAdapter {
|
||||
public List<PublicationSearchCriterion> adaptCriteriaForJpa(List<PublicationSearchCriterion> initialCriteria) {
|
||||
List<PublicationSearchCriterion> result = new LinkedList<>();
|
||||
|
||||
for (PublicationSearchCriterion criterion : initialCriteria) {
|
||||
boolean criterionAdaptationOccurred = false;
|
||||
|
||||
if (criterion.value() instanceof String criterionValue) {
|
||||
String unaccentedCriterionValue = criterionValue.replaceAll("[àáâãäåçèéêëìíîïñòóôõöùúûüýÿ]", "_");
|
||||
result.add(new PublicationSearchCriterion(
|
||||
criterion.searchField(),
|
||||
criterion.searchType(),
|
||||
unaccentedCriterionValue.toLowerCase()
|
||||
));
|
||||
criterionAdaptationOccurred = true;
|
||||
}
|
||||
|
||||
if (!criterionAdaptationOccurred) {
|
||||
result.add(criterion);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.codiki.infrastructure.publication.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.publication.model.Author;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "`user`")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AuthorEntity {
|
||||
@Id
|
||||
private UUID id;
|
||||
@Column(nullable = false)
|
||||
private String pseudo;
|
||||
// private String illustrationId;
|
||||
|
||||
public AuthorEntity(Author author) {
|
||||
this(
|
||||
author.id(),
|
||||
author.name()
|
||||
// author.illustrationId()
|
||||
);
|
||||
}
|
||||
|
||||
public Author toDomain() {
|
||||
return new Author(id, pseudo, "image");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.codiki.infrastructure.publication.model;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.publication.model.Publication;
|
||||
import org.codiki.infrastructure.category.model.CategoryEntity;
|
||||
|
||||
import static jakarta.persistence.FetchType.LAZY;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "publication")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PublicationEntity {
|
||||
@Id
|
||||
private UUID id;
|
||||
@Column(nullable = false)
|
||||
private String key;
|
||||
@Column(nullable = false)
|
||||
private String title;
|
||||
@Column(nullable = false)
|
||||
private String text;
|
||||
@Column(nullable = false)
|
||||
private String description;
|
||||
@Column(nullable = false)
|
||||
private ZonedDateTime creationDate;
|
||||
@Column(nullable = false)
|
||||
private UUID illustrationId;
|
||||
@Column(nullable = false)
|
||||
private UUID categoryId;
|
||||
@ManyToOne(fetch = LAZY)
|
||||
@JoinColumn(name = "author_id")
|
||||
private AuthorEntity author;
|
||||
|
||||
public PublicationEntity(Publication publication) {
|
||||
this(
|
||||
publication.id(),
|
||||
publication.key(),
|
||||
publication.title(),
|
||||
publication.text(),
|
||||
publication.description(),
|
||||
publication.creationDate(),
|
||||
publication.illustrationId(),
|
||||
publication.categoryId(),
|
||||
new AuthorEntity(publication.author())
|
||||
);
|
||||
}
|
||||
|
||||
public Publication toDomain() {
|
||||
return new Publication(
|
||||
id,
|
||||
key,
|
||||
title,
|
||||
text,
|
||||
description,
|
||||
creationDate,
|
||||
illustrationId,
|
||||
categoryId,
|
||||
author.toDomain()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.codiki.infrastructure.publication.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.DESCRIPTION;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.TEXT;
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.TITLE;
|
||||
import org.codiki.domain.publication.model.Publication;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchField;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class PublicationSearchResult {
|
||||
private final Publication publication;
|
||||
private final int searchScore;
|
||||
|
||||
public PublicationSearchResult(
|
||||
Publication publication,
|
||||
List<PublicationSearchCriterion> criteria
|
||||
) {
|
||||
this.publication = publication;
|
||||
|
||||
int score = 0;
|
||||
|
||||
score += computeCriterionScoreForField(TITLE, publication, criteria, 1000, 10, 3);
|
||||
score += computeCriterionScoreForField(DESCRIPTION, publication, criteria, 100, 7, 2);
|
||||
score += computeCriterionScoreForField(TEXT, publication, criteria, 100, 4, 1);
|
||||
|
||||
searchScore = score;
|
||||
}
|
||||
|
||||
private static int computeCriterionScoreForField(
|
||||
PublicationSearchField field,
|
||||
Publication publication,
|
||||
List<PublicationSearchCriterion> criteria,
|
||||
int bountyForPerfectMatch,
|
||||
int bountyForWordMatch,
|
||||
int bountyForWordContaingMatch
|
||||
) {
|
||||
return getLowercaseFieldValue(field, publication)
|
||||
.map(fieldValue ->
|
||||
criteria.stream()
|
||||
.filter(criterion -> criterion.searchType() == CONTAINS)
|
||||
.filter(criterion -> criterion.searchField() == field)
|
||||
.map(PublicationSearchCriterion::value)
|
||||
.filter(String.class::isInstance)
|
||||
.map(String.class::cast)
|
||||
.map(String::toLowerCase)
|
||||
.map(criterionValue ->
|
||||
computeCriterionScore(
|
||||
criterionValue,
|
||||
fieldValue,
|
||||
bountyForPerfectMatch,
|
||||
bountyForWordMatch,
|
||||
bountyForWordContaingMatch
|
||||
)
|
||||
)
|
||||
.mapToInt(Integer::intValue)
|
||||
.sum()
|
||||
)
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
private static Optional<String> getLowercaseFieldValue(PublicationSearchField field, Publication publication) {
|
||||
return Optional.ofNullable(
|
||||
switch (field) {
|
||||
case TITLE -> publication.title();
|
||||
case DESCRIPTION -> publication.description();
|
||||
case TEXT -> publication.text();
|
||||
default -> null;
|
||||
}
|
||||
).map(String::toLowerCase);
|
||||
}
|
||||
|
||||
private static int computeCriterionScore(
|
||||
String criterionValue,
|
||||
String publicationTitle,
|
||||
int bountyForPerfectMatch,
|
||||
int bountyForWordMatch,
|
||||
int bountyForWordContaingMatch
|
||||
) {
|
||||
int result = 0;
|
||||
|
||||
if (publicationTitle.equals(criterionValue)) {
|
||||
result = bountyForPerfectMatch;
|
||||
} else if (publicationTitle.contains(criterionValue)) {
|
||||
result = Stream.of(publicationTitle.split(" "))
|
||||
.map(titleWord -> computeWordScore(criterionValue, titleWord, bountyForWordMatch, bountyForWordContaingMatch))
|
||||
.mapToInt(Integer::intValue)
|
||||
.sum();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int computeWordScore(
|
||||
String criterionValue,
|
||||
String titleWord,
|
||||
int bountyForWordMatch,
|
||||
int bountyForWordContaingMatch
|
||||
) {
|
||||
int result = 0;
|
||||
|
||||
if (titleWord.equals(criterionValue)) {
|
||||
result = bountyForWordMatch;
|
||||
} else if (titleWord.contains(criterionValue)) {
|
||||
result = bountyForWordContaingMatch;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.codiki.infrastructure.publication.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
import org.codiki.infrastructure.publication.model.PublicationEntity;
|
||||
|
||||
public interface CustomPublicationRepository {
|
||||
List<PublicationEntity> search(List<PublicationSearchCriterion> criteria);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.codiki.infrastructure.publication.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
import org.codiki.infrastructure.publication.model.PublicationEntity;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
@Repository
|
||||
public class CustomPublicationRepositoryImpl implements CustomPublicationRepository {
|
||||
private final EntityManager entityManager;
|
||||
private final PublicationPredicateMapper publicationPredicateMapper;
|
||||
|
||||
public CustomPublicationRepositoryImpl(EntityManager entityManager, PublicationPredicateMapper publicationPredicateMapper) {
|
||||
this.entityManager = entityManager;
|
||||
this.publicationPredicateMapper = publicationPredicateMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PublicationEntity> search(final List<PublicationSearchCriterion> criteria) {
|
||||
CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
|
||||
|
||||
CriteriaQuery<PublicationEntity> query = criteriaBuilder.createQuery(PublicationEntity.class);
|
||||
Root<PublicationEntity> fromPublication = query.from(PublicationEntity.class);
|
||||
|
||||
Predicate predicate = publicationPredicateMapper.map(criteria, criteriaBuilder, fromPublication);
|
||||
|
||||
CriteriaQuery<PublicationEntity> criteriaQuery = query.select(fromPublication)
|
||||
.distinct(true)
|
||||
.where(predicate);
|
||||
|
||||
return entityManager.createQuery(criteriaQuery)
|
||||
.getResultList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.codiki.infrastructure.publication.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_PSEUDO;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
|
||||
import org.codiki.domain.publication.model.search.PublicationSearchField;
|
||||
import org.codiki.infrastructure.publication.model.PublicationEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import static jakarta.persistence.criteria.JoinType.LEFT;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.From;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
@Component
|
||||
public class PublicationPredicateMapper {
|
||||
public Predicate map(
|
||||
List<PublicationSearchCriterion> criteria,
|
||||
CriteriaBuilder criteriaBuilder,
|
||||
Root<PublicationEntity> fromPublication
|
||||
) {
|
||||
List<Predicate> criteriaPredicates = criteria.stream()
|
||||
.map(criterion -> map(criterion, criteriaBuilder, fromPublication))
|
||||
.toList();
|
||||
return criteriaBuilder.or(criteriaPredicates.toArray(new Predicate[]{}));
|
||||
}
|
||||
|
||||
private Predicate map(
|
||||
PublicationSearchCriterion criterion,
|
||||
CriteriaBuilder criteriaBuilder,
|
||||
Root<PublicationEntity> fromPublication
|
||||
) {
|
||||
return switch (criterion.searchType()) {
|
||||
case EQUALS -> mapEqualsPredicate(criteriaBuilder, fromPublication, criterion.searchField(), criterion.value());
|
||||
case CONTAINS -> mapContainsPredicate(criteriaBuilder, fromPublication, criterion.searchField(), criterion.value());
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Predicate mapEqualsPredicate(
|
||||
CriteriaBuilder criteriaBuilder,
|
||||
Root<PublicationEntity> fromPublication,
|
||||
PublicationSearchField searchField,
|
||||
Object value
|
||||
) {
|
||||
From<?, ?> from = fromPublication;
|
||||
String attributeName = searchField.name().toLowerCase();
|
||||
if (searchField == AUTHOR_PSEUDO) {
|
||||
from = fromPublication.join("author", LEFT);
|
||||
attributeName = "pseudo";
|
||||
}
|
||||
|
||||
return criteriaBuilder.equal(
|
||||
criteriaBuilder.lower(
|
||||
from.get(attributeName)
|
||||
),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
private Predicate mapContainsPredicate(
|
||||
CriteriaBuilder criteriaBuilder,
|
||||
Root<PublicationEntity> fromPublication,
|
||||
PublicationSearchField searchField,
|
||||
Object value
|
||||
) {
|
||||
From<?, ?> from = fromPublication;
|
||||
String attributeName = searchField.name().toLowerCase();
|
||||
if (searchField == AUTHOR_PSEUDO) {
|
||||
from = fromPublication.join("author", LEFT);
|
||||
attributeName = "pseudo";
|
||||
}
|
||||
|
||||
return criteriaBuilder.like(
|
||||
criteriaBuilder.lower(
|
||||
from.get(attributeName)
|
||||
),
|
||||
String.format("%%%s%%", value)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.codiki.infrastructure.publication.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.infrastructure.publication.model.PublicationEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface PublicationRepository extends JpaRepository<PublicationEntity, UUID>, CustomPublicationRepository {
|
||||
@Query("""
|
||||
SELECT p
|
||||
FROM PublicationEntity p
|
||||
JOIN FETCH p.author a
|
||||
WHERE p.id = :publicationId
|
||||
""")
|
||||
Optional<PublicationEntity> findById(@Param("publicationId") UUID publicationId);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.codiki.infrastructure.user.adapter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.user.model.RefreshToken;
|
||||
import org.codiki.domain.user.model.User;
|
||||
import org.codiki.domain.user.port.UserPort;
|
||||
import org.codiki.infrastructure.user.model.RefreshTokenEntity;
|
||||
import org.codiki.infrastructure.user.repository.RefreshTokenJpaRepository;
|
||||
import org.codiki.infrastructure.user.repository.UserJpaRepository;
|
||||
import org.codiki.infrastructure.user.model.UserEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class UserJpaAdapter implements UserPort {
|
||||
private final RefreshTokenJpaRepository refreshTokenJpaRepository;
|
||||
private final UserJpaRepository userJpaRepository;
|
||||
|
||||
public UserJpaAdapter(
|
||||
RefreshTokenJpaRepository refreshTokenJpaRepository,
|
||||
UserJpaRepository userJpaRepository
|
||||
) {
|
||||
this.refreshTokenJpaRepository = refreshTokenJpaRepository;
|
||||
this.userJpaRepository = userJpaRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<User> findById(UUID userId) {
|
||||
return userJpaRepository.findById(userId)
|
||||
.map(UserEntity::toUser);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<User> findByEmail(String userEmail) {
|
||||
return userJpaRepository.findByEmail(userEmail)
|
||||
.map(UserEntity::toUser);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<User> findAll() {
|
||||
return userJpaRepository.findAll()
|
||||
.stream()
|
||||
.map(UserEntity::toUser)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(User user) {
|
||||
UserEntity userEntity = new UserEntity(user);
|
||||
userJpaRepository.save(userEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(UUID userId) {
|
||||
return userJpaRepository.existsById(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByEmail(String email) {
|
||||
return userJpaRepository.existsByEmail(email);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RefreshToken> findRefreshTokenByUserId(UUID userId) {
|
||||
return refreshTokenJpaRepository.findByUserId(userId)
|
||||
.map(RefreshTokenEntity::toRefreshToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RefreshToken> findRefreshTokenById(UUID refreshTokenId) {
|
||||
return refreshTokenJpaRepository.findByValue(refreshTokenId)
|
||||
.map(RefreshTokenEntity::toRefreshToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(RefreshToken refreshToken) {
|
||||
RefreshTokenEntity refreshTokenEntity = new RefreshTokenEntity(refreshToken);
|
||||
refreshTokenJpaRepository.save(refreshTokenEntity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.codiki.infrastructure.user.model;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.user.model.RefreshToken;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "refresh_token")
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class RefreshTokenEntity {
|
||||
@Id
|
||||
private UUID userId;
|
||||
@Column(nullable = false)
|
||||
private UUID value;
|
||||
@Column(nullable = false)
|
||||
private ZonedDateTime expirationDate;
|
||||
|
||||
public RefreshTokenEntity(RefreshToken refreshToken) {
|
||||
userId = refreshToken.userId();
|
||||
value = refreshToken.value();
|
||||
expirationDate = refreshToken.expirationDate();
|
||||
}
|
||||
|
||||
public RefreshToken toRefreshToken() {
|
||||
return new RefreshToken(userId, value, expirationDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.codiki.infrastructure.user.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.codiki.domain.user.model.User;
|
||||
import org.codiki.domain.user.model.UserRole;
|
||||
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "`user`")
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class UserEntity {
|
||||
@Id
|
||||
private UUID id;
|
||||
@Column(nullable = false)
|
||||
private String pseudo;
|
||||
@Column(nullable = false)
|
||||
private String email;
|
||||
@Column(nullable = false)
|
||||
private String password;
|
||||
@Column
|
||||
private UUID photoId;
|
||||
@ElementCollection(targetClass = UserRole.class)
|
||||
@CollectionTable(
|
||||
name = "user_role",
|
||||
joinColumns = @JoinColumn(name = "user_id")
|
||||
)
|
||||
@Column(name = "role")
|
||||
private List<UserRole> roles;
|
||||
|
||||
public UserEntity(User user) {
|
||||
id = user.id();
|
||||
pseudo = user.pseudo();
|
||||
email = user.email();
|
||||
password = user.password();
|
||||
photoId = user.photoId();
|
||||
roles = user.roles();
|
||||
}
|
||||
|
||||
public User toUser() {
|
||||
return new User(
|
||||
id,
|
||||
pseudo,
|
||||
email,
|
||||
password,
|
||||
photoId,
|
||||
roles
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user