diff --git a/351004/Halukha/discussion/pom.xml b/351004/Halukha/discussion/pom.xml new file mode 100644 index 000000000..d20b2ce09 --- /dev/null +++ b/351004/Halukha/discussion/pom.xml @@ -0,0 +1,55 @@ + + 4.0.0 + + com.example + lab-parent + 0.0.1-SNAPSHOT + + + discussion + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-cassandra + + + org.springframework.boot + spring-boot-starter-validation + + + org.mapstruct + mapstruct + 1.5.5.Final + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + provided + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-test + test + + + io.rest-assured + rest-assured + test + + + io.rest-assured + json-path + test + + + \ No newline at end of file diff --git a/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/LabDiscussionApplication.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/LabDiscussionApplication.java new file mode 100644 index 000000000..63643bc46 --- /dev/null +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/LabDiscussionApplication.java @@ -0,0 +1,15 @@ +package com.example.lab.discussion; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LabDiscussionApplication { + + public static void main(String[] args) { + SpringApplication.run(LabDiscussionApplication.class, args); + } +} + +// netstat -ano | findstr : +// taskkill /PID /F diff --git a/351004/Halukha/src/main/java/com/example/lab/controller/PostController.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/controller/PostController.java similarity index 89% rename from 351004/Halukha/src/main/java/com/example/lab/controller/PostController.java rename to 351004/Halukha/discussion/src/main/java/com/example/lab/discussion/controller/PostController.java index be9f472fe..56613a581 100644 --- a/351004/Halukha/src/main/java/com/example/lab/controller/PostController.java +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/controller/PostController.java @@ -1,7 +1,6 @@ -package com.example.lab.controller; +package com.example.lab.discussion.controller; import java.util.List; - import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -13,9 +12,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.example.lab.dto.PostRequestTo; -import com.example.lab.dto.PostResponseTo; -import com.example.lab.service.PostService; +import com.example.lab.discussion.dto.PostRequestTo; +import com.example.lab.discussion.dto.PostResponseTo; +import com.example.lab.discussion.service.PostService; import jakarta.validation.Valid; @@ -54,4 +53,4 @@ public ResponseEntity deletePost(@PathVariable Long id) { postService.deletePost(id); return ResponseEntity.noContent().build(); } -} \ No newline at end of file +} diff --git a/351004/Halukha/src/main/java/com/example/lab/dto/PostRequestTo.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/dto/PostRequestTo.java similarity index 89% rename from 351004/Halukha/src/main/java/com/example/lab/dto/PostRequestTo.java rename to 351004/Halukha/discussion/src/main/java/com/example/lab/discussion/dto/PostRequestTo.java index 6f3aeb79c..633657d13 100644 --- a/351004/Halukha/src/main/java/com/example/lab/dto/PostRequestTo.java +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/dto/PostRequestTo.java @@ -1,4 +1,4 @@ -package com.example.lab.dto; +package com.example.lab.discussion.dto; import com.fasterxml.jackson.annotation.JsonProperty; @@ -13,6 +13,8 @@ public class PostRequestTo { @Size(min = 2, max = 2048) private String content; + public PostRequestTo() {} + public PostRequestTo( @JsonProperty("newsId") Long newsId, @JsonProperty("content") String content) { diff --git a/351004/Halukha/src/main/java/com/example/lab/dto/PostResponseTo.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/dto/PostResponseTo.java similarity index 93% rename from 351004/Halukha/src/main/java/com/example/lab/dto/PostResponseTo.java rename to 351004/Halukha/discussion/src/main/java/com/example/lab/discussion/dto/PostResponseTo.java index 76c3fde2f..2a20f4d7d 100644 --- a/351004/Halukha/src/main/java/com/example/lab/dto/PostResponseTo.java +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/dto/PostResponseTo.java @@ -1,4 +1,4 @@ -package com.example.lab.dto; +package com.example.lab.discussion.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/exception/EntityNotFoundException.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/exception/EntityNotFoundException.java new file mode 100644 index 000000000..f3c8b412b --- /dev/null +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/exception/EntityNotFoundException.java @@ -0,0 +1,12 @@ +package com.example.lab.discussion.exception; + +public class EntityNotFoundException extends RuntimeException { + private final int errorCode; + + public EntityNotFoundException(String message, int errorCode) { + super(message); + this.errorCode = errorCode; + } + + public int getErrorCode() { return errorCode; } +} \ No newline at end of file diff --git a/351004/Halukha/src/main/java/com/example/lab/exception/GlobalExceptionHandler.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/exception/GlobalExceptionHandler.java similarity index 97% rename from 351004/Halukha/src/main/java/com/example/lab/exception/GlobalExceptionHandler.java rename to 351004/Halukha/discussion/src/main/java/com/example/lab/discussion/exception/GlobalExceptionHandler.java index b3e14f2d6..5246cfaf0 100644 --- a/351004/Halukha/src/main/java/com/example/lab/exception/GlobalExceptionHandler.java +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/exception/GlobalExceptionHandler.java @@ -1,4 +1,4 @@ -package com.example.lab.exception; +package com.example.lab.discussion.exception; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; @@ -50,4 +50,4 @@ public ErrorResponse(int errorCode, String errorMessage) { public int getErrorCode() { return errorCode; } public String getErrorMessage() { return errorMessage; } } -} \ No newline at end of file +} diff --git a/351004/Halukha/src/main/java/com/example/lab/mapper/PostMapper.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/mapper/PostMapper.java similarity index 75% rename from 351004/Halukha/src/main/java/com/example/lab/mapper/PostMapper.java rename to 351004/Halukha/discussion/src/main/java/com/example/lab/discussion/mapper/PostMapper.java index 98241b181..d3b96dc74 100644 --- a/351004/Halukha/src/main/java/com/example/lab/mapper/PostMapper.java +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/mapper/PostMapper.java @@ -1,13 +1,13 @@ -package com.example.lab.mapper; +package com.example.lab.discussion.mapper; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Mappings; import org.mapstruct.factory.Mappers; -import com.example.lab.dto.PostRequestTo; -import com.example.lab.dto.PostResponseTo; -import com.example.lab.model.Post; +import com.example.lab.discussion.dto.PostRequestTo; +import com.example.lab.discussion.dto.PostResponseTo; +import com.example.lab.discussion.model.Post; @Mapper public interface PostMapper { diff --git a/351004/Halukha/src/main/java/com/example/lab/model/Post.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/model/Post.java similarity index 65% rename from 351004/Halukha/src/main/java/com/example/lab/model/Post.java rename to 351004/Halukha/discussion/src/main/java/com/example/lab/discussion/model/Post.java index 4b9eb3fe1..e0f5977c2 100644 --- a/351004/Halukha/src/main/java/com/example/lab/model/Post.java +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/model/Post.java @@ -1,29 +1,24 @@ -package com.example.lab.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +package com.example.lab.discussion.model; + +import org.springframework.data.cassandra.core.mapping.PrimaryKey; +import org.springframework.data.cassandra.core.mapping.Table; +import org.springframework.data.cassandra.core.mapping.Column; + import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -@Entity -@Table(name = "tbl_post") +@Table("tbl_post") public class Post { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") + @PrimaryKey private Long id; - @Column(name = "newsId") + @Column("newsId") private Long newsId; @NotBlank @Size(min = 2, max = 2048) - @Column(name = "content") + @Column("content") private String content; public Post() { @@ -39,23 +34,23 @@ public Long getId() { return id; } - public Long getNewsId() { - return newsId; - } - - public String getContent() { - return content; - } - public void setId(Long id) { this.id = id; } + public Long getNewsId() { + return newsId; + } + public void setNewsId(Long newsId) { this.newsId = newsId; } + public String getContent() { + return content; + } + public void setContent(String content) { this.content = content; } -} +} \ No newline at end of file diff --git a/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/repository/PostRepository.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/repository/PostRepository.java new file mode 100644 index 000000000..f87c35c27 --- /dev/null +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/repository/PostRepository.java @@ -0,0 +1,7 @@ +package com.example.lab.discussion.repository; + +import org.springframework.data.cassandra.repository.CassandraRepository; +import com.example.lab.discussion.model.Post; + +public interface PostRepository extends CassandraRepository { +} diff --git a/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/service/PostService.java b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/service/PostService.java new file mode 100644 index 000000000..b1148950e --- /dev/null +++ b/351004/Halukha/discussion/src/main/java/com/example/lab/discussion/service/PostService.java @@ -0,0 +1,80 @@ +package com.example.lab.discussion.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import com.example.lab.discussion.dto.PostRequestTo; +import com.example.lab.discussion.dto.PostResponseTo; +import com.example.lab.discussion.exception.EntityNotFoundException; +import com.example.lab.discussion.mapper.PostMapper; +import com.example.lab.discussion.model.Post; +import com.example.lab.discussion.repository.PostRepository; + +@Service +public class PostService { + + private final PostRepository postRepository; + private final PostMapper mapper = PostMapper.INSTANCE; + + private final WebClient webClient; + + public PostService(PostRepository postRepository, WebClient.Builder webClientBuilder) { + this.postRepository = postRepository; + this.webClient = webClientBuilder.baseUrl("http://localhost:24110").build(); + } + + public List getAllPost() { + return postRepository.findAll().stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } + + public PostResponseTo getPostById(Long id) { + return postRepository.findById(id) + .map(mapper::toDto) + .orElseThrow(() -> new EntityNotFoundException("Post not found", 40401)); + } + + public PostResponseTo createPost(PostRequestTo request) { + try { + Boolean exists = webClient.get() + .uri("/api/v1.0/news/{id}", request.getNewsId()) + .retrieve() + .toBodilessEntity() + .map(response -> response.getStatusCode().is2xxSuccessful()) + .block(); + + if (exists == null || !exists) { + throw new EntityNotFoundException("News not found", 40401); + } + } catch (Exception e) { + throw new EntityNotFoundException("News not found or service unavailable", 40401); + } + + Post post = new Post(); + post.setId(System.currentTimeMillis()); + post.setNewsId(request.getNewsId()); + post.setContent(request.getContent()); + + return mapper.toDto(postRepository.save(post)); + } + + public PostResponseTo updatePost(Long id, PostRequestTo request) { + Post existing = postRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Post not found", 40401)); + Post updated = mapper.updateEntity(request, existing); + updated.setId(id); + Post saved = postRepository.save(updated); + return mapper.toDto(saved); + } + + public void deletePost(Long id) { + if (!postRepository.existsById(id)) { + throw new EntityNotFoundException("Post not found", 40401); + } + postRepository.deleteById(id); + } +} diff --git a/351004/Halukha/discussion/src/main/resources/application.yaml b/351004/Halukha/discussion/src/main/resources/application.yaml new file mode 100644 index 000000000..80510f087 --- /dev/null +++ b/351004/Halukha/discussion/src/main/resources/application.yaml @@ -0,0 +1,14 @@ +server: + port: 24130 + +spring: + application: + name: discussion-service + + cassandra: + contact-points: localhost + port: 9042 + keyspace-name: distcomp + local-datacenter: datacenter1 # Исправлено здесь + schema-action: create_if_not_exists + \ No newline at end of file diff --git a/351004/Halukha/src/test/java/com/example/lab/LabApplicationTests.java b/351004/Halukha/discussion/src/test/java/com/example/lab/LabApplicationTests.java similarity index 100% rename from 351004/Halukha/src/test/java/com/example/lab/LabApplicationTests.java rename to 351004/Halukha/discussion/src/test/java/com/example/lab/LabApplicationTests.java diff --git a/351004/Halukha/src/test/java/com/example/lab/test/BaseIntegrationTest.java b/351004/Halukha/discussion/src/test/java/com/example/lab/test/BaseIntegrationTest.java similarity index 100% rename from 351004/Halukha/src/test/java/com/example/lab/test/BaseIntegrationTest.java rename to 351004/Halukha/discussion/src/test/java/com/example/lab/test/BaseIntegrationTest.java diff --git a/351004/Halukha/src/test/java/com/example/lab/test/MarkerControllerIntegrationTest.java b/351004/Halukha/discussion/src/test/java/com/example/lab/test/MarkerControllerIntegrationTest.java similarity index 100% rename from 351004/Halukha/src/test/java/com/example/lab/test/MarkerControllerIntegrationTest.java rename to 351004/Halukha/discussion/src/test/java/com/example/lab/test/MarkerControllerIntegrationTest.java diff --git a/351004/Halukha/src/test/java/com/example/lab/test/NewsControllerIntegrationTest.java b/351004/Halukha/discussion/src/test/java/com/example/lab/test/NewsControllerIntegrationTest.java similarity index 100% rename from 351004/Halukha/src/test/java/com/example/lab/test/NewsControllerIntegrationTest.java rename to 351004/Halukha/discussion/src/test/java/com/example/lab/test/NewsControllerIntegrationTest.java diff --git a/351004/Halukha/src/test/java/com/example/lab/test/PostControllerIntegrationTest.java b/351004/Halukha/discussion/src/test/java/com/example/lab/test/PostControllerIntegrationTest.java similarity index 100% rename from 351004/Halukha/src/test/java/com/example/lab/test/PostControllerIntegrationTest.java rename to 351004/Halukha/discussion/src/test/java/com/example/lab/test/PostControllerIntegrationTest.java diff --git a/351004/Halukha/src/test/java/com/example/lab/test/UserControllerIntegrationTest.java b/351004/Halukha/discussion/src/test/java/com/example/lab/test/UserControllerIntegrationTest.java similarity index 100% rename from 351004/Halukha/src/test/java/com/example/lab/test/UserControllerIntegrationTest.java rename to 351004/Halukha/discussion/src/test/java/com/example/lab/test/UserControllerIntegrationTest.java diff --git a/351004/Halukha/pom.xml b/351004/Halukha/pom.xml index ed4ef4f75..705ba106b 100644 --- a/351004/Halukha/pom.xml +++ b/351004/Halukha/pom.xml @@ -1,124 +1,32 @@ - + - + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - - com.example - lab - 0.0.1-SNAPSHOT - jar - - + org.springframework.boot spring-boot-starter-parent - 3.4.0 + 3.2.0 + com.example + lab-parent + 0.0.1-SNAPSHOT + pom + publisher + discussion + + - 18 + 17 - - - - org.mapstruct - mapstruct - 1.5.5.Final - - - org.projectlombok lombok - provided - - - - - org.springframework.boot - spring-boot-starter-web - - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - - org.springframework.boot - spring-boot-starter-validation + true - - - - com.fasterxml.jackson.core - jackson-databind - - - - - org.postgresql - postgresql - runtime - - - - - org.springframework.boot - spring-boot-starter-test - test - - - - io.rest-assured - rest-assured - test - - - - io.rest-assured - json-path - test - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.11.0 - - - - - org.mapstruct - mapstruct-processor - 1.5.5.Final - - - - org.projectlombok - lombok - 1.18.34 - - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - - + \ No newline at end of file diff --git a/351004/Halukha/publisher/pom.xml b/351004/Halukha/publisher/pom.xml new file mode 100644 index 000000000..141da605a --- /dev/null +++ b/351004/Halukha/publisher/pom.xml @@ -0,0 +1,94 @@ + + 4.0.0 + + com.example + lab-parent + 0.0.1-SNAPSHOT + + + publisher + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-validation + + + org.mapstruct + mapstruct + 1.5.5.Final + + + org.mapstruct + mapstruct-processor + 1.5.5.Final + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + io.rest-assured + rest-assured + test + + + io.rest-assured + json-path + test + + + io.jsonwebtoken + jjwt-api + 0.12.5 + + + io.jsonwebtoken + jjwt-impl + 0.12.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.5 + runtime + + + \ No newline at end of file diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/LabPublisherApplication.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/LabPublisherApplication.java new file mode 100644 index 000000000..d2cd3a4f2 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/LabPublisherApplication.java @@ -0,0 +1,17 @@ +package com.example.lab.publisher; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; + +@SpringBootApplication +@EnableCaching +public class LabPublisherApplication { + + public static void main(String[] args) { + SpringApplication.run(LabPublisherApplication.class, args); + } +} + +// docker exec -it cassandra cqlsh +// CREATE KEYSPACE distcomp WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}; \ No newline at end of file diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/config/RedisCacheConfig.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/config/RedisCacheConfig.java new file mode 100644 index 000000000..e91b6b7f7 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/config/RedisCacheConfig.java @@ -0,0 +1,77 @@ +package com.example.lab.publisher.config; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +@Configuration +public class RedisCacheConfig { + + private static final Logger log = LoggerFactory.getLogger(RedisCacheConfig.class); + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(new GenericJackson2JsonRedisSerializer())) + .entryTtl(Duration.ofMinutes(5)); + + Map perCache = new HashMap<>(); + perCache.put("users", defaults.entryTtl(Duration.ofMinutes(10))); + perCache.put("news", defaults.entryTtl(Duration.ofMinutes(5))); + perCache.put("markers", defaults.entryTtl(Duration.ofMinutes(30))); + perCache.put("newsUser", defaults.entryTtl(Duration.ofMinutes(5))); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaults) + .withInitialCacheConfigurations(perCache) + .build(); + } + + @Bean + public CacheErrorHandler cacheErrorHandler() { + return new CacheErrorHandler() { + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + swallowIfRedisDown(exception, cache, key); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + swallowIfRedisDown(exception, cache, key); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + swallowIfRedisDown(exception, cache, key); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + swallowIfRedisDown(exception, cache, null); + } + + private void swallowIfRedisDown(RuntimeException exception, Cache cache, Object key) { + if (exception instanceof RedisConnectionFailureException) { + log.warn("Redis unavailable; skipping cache operation (cache={}, key={})", cache.getName(), key); + return; + } + throw exception; + } + }; + } +} + diff --git a/351004/Halukha/src/main/java/com/example/lab/controller/MarkerController.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/MarkerController.java similarity index 86% rename from 351004/Halukha/src/main/java/com/example/lab/controller/MarkerController.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/MarkerController.java index f3647c1d3..26b9de245 100644 --- a/351004/Halukha/src/main/java/com/example/lab/controller/MarkerController.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/MarkerController.java @@ -1,4 +1,4 @@ -package com.example.lab.controller; +package com.example.lab.publisher.controller; import java.util.List; @@ -13,9 +13,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.example.lab.dto.MarkerRequestTo; -import com.example.lab.dto.MarkerResponseTo; -import com.example.lab.service.MarkerService; +import com.example.lab.publisher.dto.MarkerRequestTo; +import com.example.lab.publisher.dto.MarkerResponseTo; +import com.example.lab.publisher.service.MarkerService; import jakarta.validation.Valid; @@ -45,7 +45,8 @@ public ResponseEntity createMarker(@Valid @RequestBody MarkerR } @PutMapping("/{id}") - public ResponseEntity updateMarker(@PathVariable Long id, @Valid @RequestBody MarkerRequestTo marker) { + public ResponseEntity updateMarker(@PathVariable Long id, + @Valid @RequestBody MarkerRequestTo marker) { return ResponseEntity.ok(markerService.updateMarker(id, marker)); } @@ -54,4 +55,4 @@ public ResponseEntity deleteMarker(@PathVariable Long id) { markerService.deleteMarker(id); return ResponseEntity.noContent().build(); } -} \ No newline at end of file +} diff --git a/351004/Halukha/src/main/java/com/example/lab/controller/NewsController.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/NewsController.java similarity index 88% rename from 351004/Halukha/src/main/java/com/example/lab/controller/NewsController.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/NewsController.java index 25a75c049..d835f368c 100644 --- a/351004/Halukha/src/main/java/com/example/lab/controller/NewsController.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/NewsController.java @@ -1,4 +1,4 @@ -package com.example.lab.controller; +package com.example.lab.publisher.controller; import java.util.List; @@ -13,10 +13,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.example.lab.dto.NewsRequestTo; -import com.example.lab.dto.NewsResponseTo; -import com.example.lab.dto.UserResponseTo; -import com.example.lab.service.NewsService; +import com.example.lab.publisher.dto.NewsRequestTo; +import com.example.lab.publisher.dto.NewsResponseTo; +import com.example.lab.publisher.dto.UserResponseTo; +import com.example.lab.publisher.service.NewsService; import jakarta.validation.Valid; @@ -60,4 +60,4 @@ public ResponseEntity deleteNews(@PathVariable Long id) { public ResponseEntity getUserByNewsId(@PathVariable Long id) { return ResponseEntity.ok(newsService.getUserByNewsId(id)); } -} \ No newline at end of file +} diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/PostProxyController.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/PostProxyController.java new file mode 100644 index 000000000..beace4d09 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/PostProxyController.java @@ -0,0 +1,94 @@ +package com.example.lab.publisher.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import com.example.lab.publisher.service.PostRedisCacheService; + +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/v1.0/posts") +public class PostProxyController { + + private final WebClient webClient; + private final PostRedisCacheService postCache; + + public PostProxyController(WebClient.Builder webClientBuilder, PostRedisCacheService postCache) { + this.webClient = webClientBuilder.baseUrl("http://localhost:24130").build(); + this.postCache = postCache; + } + + @GetMapping + public Mono> getAllPosts() { + return webClient.get() + .uri("/api/v1.0/posts") + .exchangeToMono(response -> response.toEntity(Object.class)); + } + + @GetMapping("/{id}") + public Mono> getPostById(@PathVariable Long id) { + Object cached = postCache.get(id); + if (cached != null) { + return Mono.just(ResponseEntity.ok(cached)); + } + return webClient.get() + .uri("/api/v1.0/posts/{id}", id) + .exchangeToMono(response -> response.toEntity(Object.class)) + .doOnNext(entity -> { + if (entity.getStatusCode().is2xxSuccessful() && entity.getBody() != null) { + postCache.put(id, entity.getBody()); + } + }); + } + + @PostMapping + public Mono> createPost(@RequestBody Object post) { + return webClient.post() + .uri("/api/v1.0/posts") + .bodyValue(post) + .retrieve() + .toEntity(Object.class) + .doOnNext(entity -> { + if (entity.getStatusCode().is2xxSuccessful() && entity.getBody() instanceof java.util.Map map) { + Object idValue = map.get("id"); + if (idValue instanceof Number n) { + postCache.put(n.longValue(), entity.getBody()); + } + } + }) + .onErrorResume(WebClientResponseException.class, e -> { + Object errorBody = e.getResponseBodyAs(Object.class); + return Mono.just(ResponseEntity + .status(e.getStatusCode()) + .body(errorBody)); + }); + } + + @PutMapping("/{id}") + public Mono> updatePost(@PathVariable Long id, @RequestBody Object post) { + return webClient.put() + .uri("/api/v1.0/posts/{id}", id) + .bodyValue(post) + .exchangeToMono(response -> response.toEntity(Object.class)) + .doOnNext(entity -> { + if (entity.getStatusCode().is2xxSuccessful() && entity.getBody() != null) { + postCache.put(id, entity.getBody()); + } + }); + } + + @DeleteMapping("/{id}") + public Mono> deletePost(@PathVariable Long id) { + return webClient.delete() + .uri("/api/v1.0/posts/{id}", id) + .exchangeToMono(response -> response.toBodilessEntity()) + .doOnNext(entity -> { + if (entity.getStatusCode().is2xxSuccessful()) { + postCache.evict(id); + } + }); + } +} \ No newline at end of file diff --git a/351004/Halukha/src/main/java/com/example/lab/controller/UserController.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/UserController.java similarity index 89% rename from 351004/Halukha/src/main/java/com/example/lab/controller/UserController.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/UserController.java index 384c94b5a..9b179363d 100644 --- a/351004/Halukha/src/main/java/com/example/lab/controller/UserController.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/UserController.java @@ -1,4 +1,4 @@ -package com.example.lab.controller; +package com.example.lab.publisher.controller; import java.util.List; @@ -13,9 +13,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.example.lab.dto.UserRequestTo; -import com.example.lab.dto.UserResponseTo; -import com.example.lab.service.UserService; +import com.example.lab.publisher.dto.UserRequestTo; +import com.example.lab.publisher.dto.UserResponseTo; +import com.example.lab.publisher.service.UserService; import jakarta.validation.Valid; @@ -54,4 +54,4 @@ public ResponseEntity deleteUser(@PathVariable Long id) { userService.deleteUser(id); return ResponseEntity.noContent().build(); } -} \ No newline at end of file +} diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/MarkerControllerV2.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/MarkerControllerV2.java new file mode 100644 index 000000000..7488f3482 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/MarkerControllerV2.java @@ -0,0 +1,62 @@ +package com.example.lab.publisher.controller.v2; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RestController; + +import com.example.lab.publisher.dto.MarkerRequestTo; +import com.example.lab.publisher.dto.MarkerResponseTo; +import com.example.lab.publisher.service.MarkerService; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api/v2.0/markers") +public class MarkerControllerV2 { + + private final MarkerService markerService; + + public MarkerControllerV2(MarkerService markerService) { + this.markerService = markerService; + } + + @GetMapping + public ResponseEntity> getAllMarker() { + return ResponseEntity.ok(markerService.getAllMarker()); + } + + @GetMapping("/{id}") + public ResponseEntity getMarker(@PathVariable Long id) { + return ResponseEntity.ok(markerService.getMarkerById(id)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity createMarker(@Valid @RequestBody MarkerRequestTo marker) { + return ResponseEntity.status(HttpStatus.CREATED).body(markerService.createMarker(marker)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updateMarker(@PathVariable Long id, @Valid @RequestBody MarkerRequestTo marker) { + return ResponseEntity.ok(markerService.updateMarker(id, marker)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteMarker(@PathVariable Long id) { + markerService.deleteMarker(id); + return ResponseEntity.noContent().build(); + } +} + diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/NewsControllerV2.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/NewsControllerV2.java new file mode 100644 index 000000000..2875f818b --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/NewsControllerV2.java @@ -0,0 +1,83 @@ +package com.example.lab.publisher.controller.v2; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RestController; + +import com.example.lab.publisher.dto.NewsRequestTo; +import com.example.lab.publisher.dto.NewsResponseTo; +import com.example.lab.publisher.dto.UserResponseTo; +import com.example.lab.publisher.security.OwnershipService; +import com.example.lab.publisher.service.NewsService; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api/v2.0/news") +public class NewsControllerV2 { + + private final NewsService newsService; + private final OwnershipService ownership; + + public NewsControllerV2(NewsService newsService, OwnershipService ownership) { + this.newsService = newsService; + this.ownership = ownership; + } + + @GetMapping + public ResponseEntity> getAllNews() { + return ResponseEntity.ok(newsService.getAllNews()); + } + + @GetMapping("/{id}") + public ResponseEntity getNews(@PathVariable Long id) { + return ResponseEntity.ok(newsService.getNewsById(id)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN') or hasRole('CUSTOMER')") + public ResponseEntity createNews(@Valid @RequestBody NewsRequestTo news) { + Long me = ownership.currentUserId(); + NewsRequestTo adjusted = news; + if (me != null) { + adjusted = new NewsRequestTo(me, news.getTitle(), news.getContent(), news.getCreated(), news.getModified(), + news.getMarkers()); + } + return ResponseEntity.status(HttpStatus.CREATED).body(newsService.createNews(adjusted)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or @ownership.canModifyNews(#id)") + public ResponseEntity updateNews(@PathVariable Long id, @Valid @RequestBody NewsRequestTo news) { + Long me = ownership.currentUserId(); + NewsRequestTo adjusted = news; + if (me != null) { + adjusted = new NewsRequestTo(me, news.getTitle(), news.getContent(), news.getCreated(), news.getModified(), + news.getMarkers()); + } + return ResponseEntity.ok(newsService.updateNews(id, adjusted)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or @ownership.canModifyNews(#id)") + public ResponseEntity deleteNews(@PathVariable Long id) { + newsService.deleteNews(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/user/{id}") + public ResponseEntity getUserByNewsId(@PathVariable Long id) { + return ResponseEntity.ok(newsService.getUserByNewsId(id)); + } +} + diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/PostProxyControllerV2.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/PostProxyControllerV2.java new file mode 100644 index 000000000..beb9e2ff0 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/PostProxyControllerV2.java @@ -0,0 +1,127 @@ +package com.example.lab.publisher.controller.v2; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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.RestController; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import com.example.lab.publisher.exception.GlobalExceptionHandler.ErrorResponse; +import com.example.lab.publisher.security.OwnershipService; + +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/v2.0/posts") +public class PostProxyControllerV2 { + + private final WebClient webClient; + private final OwnershipService ownership; + + public PostProxyControllerV2(WebClient.Builder webClientBuilder, OwnershipService ownership) { + this.webClient = webClientBuilder.baseUrl("http://localhost:24130").build(); + this.ownership = ownership; + } + + @GetMapping + public Mono> getAllPosts() { + return webClient.get() + .uri("/api/v1.0/posts") + .exchangeToMono(response -> response.toEntity(Object.class)); + } + + @GetMapping("/{id}") + public Mono> getPostById(@PathVariable Long id) { + return webClient.get() + .uri("/api/v1.0/posts/{id}", id) + .exchangeToMono(response -> response.toEntity(Object.class)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN') or hasRole('CUSTOMER')") + public Mono> createPost(@RequestBody Object post) { + Long newsId = extractNewsId(post); + if (!canWrite(newsId)) { + return Mono.just(ResponseEntity.status(403).body(new ErrorResponse(40301, "Forbidden"))); + } + return webClient.post() + .uri("/api/v1.0/posts") + .bodyValue(post) + .retrieve() + .toEntity(Object.class) + .onErrorResume(WebClientResponseException.class, e -> { + Object errorBody = e.getResponseBodyAs(Object.class); + return Mono.just(ResponseEntity.status(e.getStatusCode()).body(errorBody)); + }); + } + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or hasRole('CUSTOMER')") + public Mono> updatePost(@PathVariable Long id, @RequestBody Object post) { + Long newsId = extractNewsId(post); + if (!canWrite(newsId)) { + return Mono.just(ResponseEntity.status(403).body(new ErrorResponse(40301, "Forbidden"))); + } + return webClient.put() + .uri("/api/v1.0/posts/{id}", id) + .bodyValue(post) + .exchangeToMono(response -> response.toEntity(Object.class)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or hasRole('CUSTOMER')") + public Mono> deletePost(@PathVariable Long id) { + return webClient.get() + .uri("/api/v1.0/posts/{id}", id) + .exchangeToMono(response -> response.toEntity(Object.class)) + .flatMap(entity -> { + Long newsId = extractNewsId(entity.getBody()); + if (!canWrite(newsId)) { + return Mono.just(ResponseEntity.status(403).build()); + } + return webClient.delete() + .uri("/api/v1.0/posts/{id}", id) + .exchangeToMono(resp -> resp.toBodilessEntity()); + }); + } + + private boolean canWrite(Long newsId) { + if (newsId == null) { + return false; + } + if (isAdmin()) { + return true; + } + return ownership.canModifyNews(newsId); + } + + private boolean isAdmin() { + Authentication a = SecurityContextHolder.getContext().getAuthentication(); + if (a == null) { + return false; + } + return a.getAuthorities().stream().anyMatch(ga -> "ROLE_ADMIN".equals(ga.getAuthority())); + } + + private Long extractNewsId(Object body) { + if (body instanceof Map map) { + Object v = map.get("newsId"); + if (v instanceof Number n) { + return n.longValue(); + } + } + return null; + } +} + diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/UserControllerV2.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/UserControllerV2.java new file mode 100644 index 000000000..b07d0e258 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/controller/v2/UserControllerV2.java @@ -0,0 +1,80 @@ +package com.example.lab.publisher.controller.v2; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.lab.publisher.dto.UserRequestTo; +import com.example.lab.publisher.dto.UserResponseTo; +import com.example.lab.publisher.exception.EntityNotFoundException; +import com.example.lab.publisher.mapper.UserMapper; +import com.example.lab.publisher.model.User; +import com.example.lab.publisher.repository.UserRepository; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api/v2.0/users") +public class UserControllerV2 { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final UserMapper mapper = UserMapper.INSTANCE; + + public UserControllerV2(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @GetMapping + public ResponseEntity> getAllUsers() { + List users = userRepository.findAll().stream().map(mapper::toDto).toList(); + return ResponseEntity.ok(users); + } + + @GetMapping("/{id}") + public ResponseEntity getUser(@PathVariable Long id) { + return ResponseEntity.ok(userRepository.findById(id).map(mapper::toDto) + .orElseThrow(() -> new EntityNotFoundException("User not found", 40401))); + } + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or @ownership.canAccessUser(#id)") + public ResponseEntity updateUser(@PathVariable Long id, @Valid @RequestBody UserRequestTo req) { + User existing = userRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("User not found", 40401)); + + User updated = mapper.updateEntity(req, existing); + updated.setId(id); + updated.setPassword(passwordEncoder.encode(req.getPassword())); + User saved = userRepository.save(updated); + return ResponseEntity.ok(mapper.toDto(saved)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN') or @ownership.canAccessUser(#id)") + public ResponseEntity deleteUser(@PathVariable Long id) { + if (!userRepository.existsById(id)) { + throw new EntityNotFoundException("User not found", 40401); + } + userRepository.deleteById(id); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + static Authentication auth() { + return SecurityContextHolder.getContext().getAuthentication(); + } +} + diff --git a/351004/Halukha/src/main/java/com/example/lab/dto/MarkerRequestTo.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/MarkerRequestTo.java similarity index 91% rename from 351004/Halukha/src/main/java/com/example/lab/dto/MarkerRequestTo.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/MarkerRequestTo.java index 9000686d0..49e4d18b7 100644 --- a/351004/Halukha/src/main/java/com/example/lab/dto/MarkerRequestTo.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/MarkerRequestTo.java @@ -1,4 +1,4 @@ -package com.example.lab.dto; +package com.example.lab.publisher.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/351004/Halukha/src/main/java/com/example/lab/dto/MarkerResponseTo.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/MarkerResponseTo.java similarity index 91% rename from 351004/Halukha/src/main/java/com/example/lab/dto/MarkerResponseTo.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/MarkerResponseTo.java index 5554df9f3..70909ec84 100644 --- a/351004/Halukha/src/main/java/com/example/lab/dto/MarkerResponseTo.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/MarkerResponseTo.java @@ -1,4 +1,4 @@ -package com.example.lab.dto; +package com.example.lab.publisher.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/351004/Halukha/src/main/java/com/example/lab/dto/NewsRequestTo.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/NewsRequestTo.java similarity index 90% rename from 351004/Halukha/src/main/java/com/example/lab/dto/NewsRequestTo.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/NewsRequestTo.java index fdf1aceef..8d9f8a04f 100644 --- a/351004/Halukha/src/main/java/com/example/lab/dto/NewsRequestTo.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/NewsRequestTo.java @@ -1,4 +1,4 @@ -package com.example.lab.dto; +package com.example.lab.publisher.dto; import java.time.LocalDateTime; import java.util.List; @@ -23,27 +23,27 @@ public class NewsRequestTo { @Size(min = 4, max = 2048) private String content; - private List markers; - @DateTimeFormat(iso = ISO.DATE_TIME) private LocalDateTime created; @DateTimeFormat(iso = ISO.DATE_TIME) private LocalDateTime modified; + private List markers; + public NewsRequestTo( @JsonProperty("userId") Long userId, @JsonProperty("title") String title, @JsonProperty("content") String content, - @JsonProperty("markers") List markers, @JsonProperty("created") LocalDateTime created, - @JsonProperty("modified") LocalDateTime modified) { + @JsonProperty("modified") LocalDateTime modified, + @JsonProperty("markers") List markers) { this.userId = userId; this.title = title; this.content = content; - this.markers = markers; this.created = created; this.modified = modified; + this.markers = markers; } public Long getUserId() { @@ -58,10 +58,6 @@ public String getContent() { return content; } - public List getMarkers() { - return markers; - } - public LocalDateTime getCreated() { return created; } @@ -69,4 +65,8 @@ public LocalDateTime getCreated() { public LocalDateTime getModified() { return modified; } + + public List getMarkers() { + return markers; + } } diff --git a/351004/Halukha/src/main/java/com/example/lab/dto/NewsResponseTo.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/NewsResponseTo.java similarity index 96% rename from 351004/Halukha/src/main/java/com/example/lab/dto/NewsResponseTo.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/NewsResponseTo.java index ccfd206d7..f154ff0a1 100644 --- a/351004/Halukha/src/main/java/com/example/lab/dto/NewsResponseTo.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/NewsResponseTo.java @@ -1,4 +1,4 @@ -package com.example.lab.dto; +package com.example.lab.publisher.dto; import java.time.LocalDateTime; diff --git a/351004/Halukha/src/main/java/com/example/lab/dto/UserRequestTo.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/UserRequestTo.java similarity index 66% rename from 351004/Halukha/src/main/java/com/example/lab/dto/UserRequestTo.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/UserRequestTo.java index 664fdd348..8cd480a87 100644 --- a/351004/Halukha/src/main/java/com/example/lab/dto/UserRequestTo.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/UserRequestTo.java @@ -1,4 +1,4 @@ -package com.example.lab.dto; +package com.example.lab.publisher.dto; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; @@ -18,22 +18,22 @@ public class UserRequestTo { @NotBlank @Size(min = 2, max = 64) - private final String firstName; + private final String firstname; @NotBlank @Size(min = 2, max = 64) - private final String lastName; + private final String lastname; @JsonCreator public UserRequestTo( @JsonProperty("login") String login, @JsonProperty("password") String password, - @JsonProperty("firstname") String firstName, - @JsonProperty("lastname") String lastName) { + @JsonProperty("firstname") String firstname, + @JsonProperty("lastname") String lastname) { this.login = login; this.password = password; - this.firstName = firstName; - this.lastName = lastName; + this.firstname = firstname; + this.lastname = lastname; } public String getLogin() { @@ -44,11 +44,11 @@ public String getPassword() { return password; } - public String getFirstName() { - return firstName; + public String getFirstname() { + return firstname; } - public String getLastName() { - return lastName; + public String getLastname() { + return lastname; } } \ No newline at end of file diff --git a/351004/Halukha/src/main/java/com/example/lab/dto/UserResponseTo.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/UserResponseTo.java similarity index 63% rename from 351004/Halukha/src/main/java/com/example/lab/dto/UserResponseTo.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/UserResponseTo.java index b6673e7df..f95282bca 100644 --- a/351004/Halukha/src/main/java/com/example/lab/dto/UserResponseTo.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/dto/UserResponseTo.java @@ -1,4 +1,4 @@ -package com.example.lab.dto; +package com.example.lab.publisher.dto; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; @@ -8,21 +8,21 @@ public class UserResponseTo { private Long id; private String login; private String password; - private String firstName; - private String lastName; + private String firstname; + private String lastname; @JsonCreator public UserResponseTo( @JsonProperty("id") Long id, @JsonProperty("login") String login, @JsonProperty("password") String password, - @JsonProperty("firstname") String firstName, - @JsonProperty("lastname") String lastName) { + @JsonProperty("firstname") String firstname, + @JsonProperty("lastname") String lastname) { this.id = id; this.login = login; this.password = password; - this.firstName = firstName; - this.lastName = lastName; + this.firstname = firstname; + this.lastname = lastname; } public Long getId() { @@ -37,11 +37,11 @@ public String getPassword() { return password; } - public String getFirstName() { - return firstName; + public String getFirstname() { + return firstname; } - public String getLastName() { - return lastName; + public String getLastname() { + return lastname; } } diff --git a/351004/Halukha/src/main/java/com/example/lab/exception/EntityNotFoundException.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/exception/EntityNotFoundException.java similarity index 86% rename from 351004/Halukha/src/main/java/com/example/lab/exception/EntityNotFoundException.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/exception/EntityNotFoundException.java index d33557ae5..7207a03ab 100644 --- a/351004/Halukha/src/main/java/com/example/lab/exception/EntityNotFoundException.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/exception/EntityNotFoundException.java @@ -1,4 +1,4 @@ -package com.example.lab.exception; +package com.example.lab.publisher.exception; public class EntityNotFoundException extends RuntimeException { private final int errorCode; diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/exception/GlobalExceptionHandler.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..cf6112837 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/exception/GlobalExceptionHandler.java @@ -0,0 +1,74 @@ +package com.example.lab.publisher.exception; + +import java.nio.file.AccessDeniedException; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleEntityNotFound(EntityNotFoundException ex) { + ErrorResponse error = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDuplicate(DataIntegrityViolationException ex) { + ErrorResponse error = new ErrorResponse(40301, "Duplicate entry"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + String msg = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .findFirst() + .orElse("Validation failed"); + ErrorResponse error = new ErrorResponse(40001, msg); + return ResponseEntity.badRequest().body(error); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneric(Exception ex) { + ErrorResponse error = new ErrorResponse(50001, "Internal server error"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException(AuthenticationException e) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse(40101, "Invalid login or password")); + } + + @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse(40301, "У вас нет прав на это действие")); + } + + public static class ErrorResponse { + private final int errorCode; + private final String errorMessage; + + public ErrorResponse(int errorCode, String errorMessage) { + this.errorCode = errorCode; + this.errorMessage = errorMessage; + } + + public int getErrorCode() { + return errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + } +} diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/kafka/KafkaMessage.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/kafka/KafkaMessage.java new file mode 100644 index 000000000..8388577f5 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/kafka/KafkaMessage.java @@ -0,0 +1,37 @@ +package com.example.lab.publisher.kafka; + +import java.time.LocalDateTime; + +public class KafkaMessage { + private String eventType; // CREATE, UPDATE, DELETE + private String entityType; // User, News, Marker + private T data; + private LocalDateTime timestamp; + private String operationId; + + public KafkaMessage() {} + + public KafkaMessage(String eventType, String entityType, T data) { + this.eventType = eventType; + this.entityType = entityType; + this.data = data; + this.timestamp = LocalDateTime.now(); + this.operationId = java.util.UUID.randomUUID().toString(); + } + + // Getters and Setters + public String getEventType() { return eventType; } + public void setEventType(String eventType) { this.eventType = eventType; } + + public String getEntityType() { return entityType; } + public void setEntityType(String entityType) { this.entityType = entityType; } + + public T getData() { return data; } + public void setData(T data) { this.data = data; } + + public LocalDateTime getTimestamp() { return timestamp; } + public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; } + + public String getOperationId() { return operationId; } + public void setOperationId(String operationId) { this.operationId = operationId; } +} \ No newline at end of file diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/kafka/KafkaProducerService.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/kafka/KafkaProducerService.java new file mode 100644 index 000000000..f64222e3c --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/kafka/KafkaProducerService.java @@ -0,0 +1,126 @@ +package com.example.lab.publisher.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Service; + +import com.example.lab.publisher.dto.MarkerResponseTo; +import com.example.lab.publisher.dto.NewsResponseTo; +import com.example.lab.publisher.dto.UserResponseTo; +import com.example.lab.publisher.dto.kafka.KafkaMessage; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.util.concurrent.CompletableFuture; + +@Service +public class KafkaProducerService { + + private static final Logger logger = LoggerFactory.getLogger(KafkaProducerService.class); + + @Autowired + private KafkaTemplate kafkaTemplate; + + @Value("${app.kafka.topics.user:user-topic}") + private String userTopic; + + @Value("${app.kafka.topics.news:news-topic}") + private String newsTopic; + + @Value("${app.kafka.topics.marker:marker-topic}") + private String markerTopic; + + private final ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()); + + // ==================== USER EVENTS ==================== + + public void sendUserCreated(UserResponseTo user) { + sendMessage(userTopic, "CREATE", "User", user); + } + + public void sendUserUpdated(UserResponseTo user) { + sendMessage(userTopic, "UPDATE", "User", user); + } + + public void sendUserDeleted(Long userId) { + UserResponseTo deletedUser = new UserResponseTo(); + deletedUser.setId(userId); + sendMessage(userTopic, "DELETE", "User", deletedUser); + } + + // ==================== NEWS EVENTS ==================== + + public void sendNewsCreated(NewsResponseTo news) { + sendMessage(newsTopic, "CREATE", "News", news); + } + + public void sendNewsUpdated(NewsResponseTo news) { + sendMessage(newsTopic, "UPDATE", "News", news); + } + + public void sendNewsDeleted(Long newsId) { + NewsResponseTo deletedNews = new NewsResponseTo(); + deletedNews.setId(newsId); + sendMessage(newsTopic, "DELETE", "News", deletedNews); + } + + // ==================== MARKER EVENTS ==================== + + public void sendMarkerCreated(MarkerResponseTo marker) { + sendMessage(markerTopic, "CREATE", "Marker", marker); + } + + public void sendMarkerUpdated(MarkerResponseTo marker) { + sendMessage(markerTopic, "UPDATE", "Marker", marker); + } + + public void sendMarkerDeleted(Long markerId) { + MarkerResponseTo deletedMarker = new MarkerResponseTo(); + deletedMarker.setId(markerId); + sendMessage(markerTopic, "DELETE", "Marker", deletedMarker); + } + + // ==================== PRIVATE METHODS ==================== + + private void sendMessage(String topic, String eventType, String entityType, T data) { + try { + KafkaMessage message = new KafkaMessage<>(eventType, entityType, data); + String jsonMessage = objectMapper.writeValueAsString(message); + String key = entityType + "_" + getEntityId(data); + + CompletableFuture> future = + kafkaTemplate.send(topic, key, jsonMessage); + + future.whenComplete((result, ex) -> { + if (ex == null) { + logger.info("Sent message to topic {}: event={}, entity={}, id={}, offset={}", + topic, eventType, entityType, getEntityId(data), + result.getRecordMetadata().offset()); + } else { + logger.error("Failed to send message to topic {}: event={}, entity={}, id={}", + topic, eventType, entityType, getEntityId(data), ex); + } + }); + + } catch (JsonProcessingException e) { + logger.error("Failed to serialize message for topic: {}", topic, e); + } + } + + private Object getEntityId(T data) { + if (data instanceof UserResponseTo) { + return ((UserResponseTo) data).getId(); + } else if (data instanceof NewsResponseTo) { + return ((NewsResponseTo) data).getId(); + } else if (data instanceof MarkerResponseTo) { + return ((MarkerResponseTo) data).getId(); + } + return "unknown"; + } +} \ No newline at end of file diff --git a/351004/Halukha/src/main/java/com/example/lab/mapper/MarkerMapper.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/mapper/MarkerMapper.java similarity index 74% rename from 351004/Halukha/src/main/java/com/example/lab/mapper/MarkerMapper.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/mapper/MarkerMapper.java index 087293397..316bd249c 100644 --- a/351004/Halukha/src/main/java/com/example/lab/mapper/MarkerMapper.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/mapper/MarkerMapper.java @@ -1,13 +1,13 @@ -package com.example.lab.mapper; +package com.example.lab.publisher.mapper; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Mappings; import org.mapstruct.factory.Mappers; -import com.example.lab.dto.MarkerRequestTo; -import com.example.lab.dto.MarkerResponseTo; -import com.example.lab.model.Marker; +import com.example.lab.publisher.dto.MarkerRequestTo; +import com.example.lab.publisher.dto.MarkerResponseTo; +import com.example.lab.publisher.model.Marker; @Mapper public interface MarkerMapper { diff --git a/351004/Halukha/src/main/java/com/example/lab/mapper/NewsMapper.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/mapper/NewsMapper.java similarity index 82% rename from 351004/Halukha/src/main/java/com/example/lab/mapper/NewsMapper.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/mapper/NewsMapper.java index 1264ab64f..7be54fa3b 100644 --- a/351004/Halukha/src/main/java/com/example/lab/mapper/NewsMapper.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/mapper/NewsMapper.java @@ -1,13 +1,13 @@ -package com.example.lab.mapper; +package com.example.lab.publisher.mapper; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Mappings; import org.mapstruct.factory.Mappers; -import com.example.lab.dto.NewsRequestTo; -import com.example.lab.dto.NewsResponseTo; -import com.example.lab.model.News; +import com.example.lab.publisher.dto.NewsRequestTo; +import com.example.lab.publisher.dto.NewsResponseTo; +import com.example.lab.publisher.model.News; @Mapper public interface NewsMapper { @@ -25,9 +25,9 @@ public interface NewsMapper { @Mapping(target = "userId", source = "dto.userId"), @Mapping(target = "title", source = "dto.title"), @Mapping(target = "content", source = "dto.content"), - @Mapping(target = "markers", ignore = true), @Mapping(target = "created", source = "dto.created"), @Mapping(target = "modified", source = "dto.modified"), + @Mapping(target = "markers", ignore = true), }) News updateEntity(NewsRequestTo dto, News existing); } diff --git a/351004/Halukha/src/main/java/com/example/lab/mapper/UserMapper.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/mapper/UserMapper.java similarity index 66% rename from 351004/Halukha/src/main/java/com/example/lab/mapper/UserMapper.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/mapper/UserMapper.java index fe218483f..6f1884157 100644 --- a/351004/Halukha/src/main/java/com/example/lab/mapper/UserMapper.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/mapper/UserMapper.java @@ -1,13 +1,13 @@ -package com.example.lab.mapper; +package com.example.lab.publisher.mapper; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Mappings; import org.mapstruct.factory.Mappers; -import com.example.lab.dto.UserRequestTo; -import com.example.lab.dto.UserResponseTo; -import com.example.lab.model.User; +import com.example.lab.publisher.dto.UserRequestTo; +import com.example.lab.publisher.dto.UserResponseTo; +import com.example.lab.publisher.model.User; @Mapper public interface UserMapper { @@ -22,8 +22,8 @@ public interface UserMapper { @Mappings({ @Mapping(target = "login", source = "dto.login"), @Mapping(target = "password", source = "dto.password"), - @Mapping(target = "firstName", source = "dto.firstName"), - @Mapping(target = "lastName", source = "dto.lastName"), + @Mapping(target = "firstname", source = "dto.firstname"), + @Mapping(target = "lastname", source = "dto.lastname"), @Mapping(target = "id", ignore = true) }) User updateEntity(UserRequestTo dto, User existing); diff --git a/351004/Halukha/src/main/java/com/example/lab/model/Marker.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/model/Marker.java similarity index 95% rename from 351004/Halukha/src/main/java/com/example/lab/model/Marker.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/model/Marker.java index 6e7b5383d..a5f48a8cf 100644 --- a/351004/Halukha/src/main/java/com/example/lab/model/Marker.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/model/Marker.java @@ -1,4 +1,4 @@ -package com.example.lab.model; +package com.example.lab.publisher.model; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/351004/Halukha/src/main/java/com/example/lab/model/News.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/model/News.java similarity index 91% rename from 351004/Halukha/src/main/java/com/example/lab/model/News.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/model/News.java index e994a2d17..5e56cff83 100644 --- a/351004/Halukha/src/main/java/com/example/lab/model/News.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/model/News.java @@ -1,4 +1,4 @@ -package com.example.lab.model; +package com.example.lab.publisher.model; import java.time.LocalDateTime; import java.util.ArrayList; @@ -32,7 +32,7 @@ public class News { @NotBlank @Size(min = 2, max = 64) - @Column(name = "title", nullable = false, unique = true) + @Column(name = "title", unique = true) private String title; @NotBlank @@ -40,9 +40,6 @@ public class News { @Column(name = "content") private String content; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - private List markers = new ArrayList<>(); - @DateTimeFormat(iso = ISO.DATE_TIME) @Column(name = "created") private LocalDateTime created; @@ -51,17 +48,22 @@ public class News { @Column(name = "modified") private LocalDateTime modified; + // Выражение связи many-to-many с таблицей tbl_markers + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + private List markers = new ArrayList<>(); + public News() { } - public News(Long id, Long userId, String title, String content, List markers, LocalDateTime created, LocalDateTime modified) { + public News(Long id, Long userId, String title, String content, LocalDateTime created, LocalDateTime modified, + List markers) { this.id = id; this.userId = userId; this.title = title; this.content = content; - this.markers = markers; this.created = created; this.modified = modified; + this.markers = markers; } public Long getId() { diff --git a/351004/Halukha/src/main/java/com/example/lab/model/User.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/model/User.java similarity index 57% rename from 351004/Halukha/src/main/java/com/example/lab/model/User.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/model/User.java index 35e863c2a..171810e95 100644 --- a/351004/Halukha/src/main/java/com/example/lab/model/User.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/model/User.java @@ -1,7 +1,9 @@ -package com.example.lab.model; +package com.example.lab.publisher.model; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -9,6 +11,8 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +import com.example.lab.publisher.security.Role; + @Entity @Table(name = "tbl_user") public class User { @@ -20,7 +24,7 @@ public class User { @NotBlank @Size(min = 2, max = 64) - @Column(name = "login", nullable = false, unique = true) + @Column(name = "login", unique = true) private String login; @NotBlank @@ -31,21 +35,25 @@ public class User { @NotBlank @Size(min = 2, max = 64) @Column(name = "firstname") - private String firstName; + private String firstname; @NotBlank @Size(min = 2, max = 64) @Column(name = "lastname") - private String lastName; + private String lastname; + + @Enumerated(EnumType.STRING) + @Column(name = "role") + private Role role = Role.CUSTOMER; public User() { } - public User(String login, String password, String firstName, String lastName) { + public User(String login, String password, String firstname, String lastname) { this.login = login; this.password = password; - this.firstName = firstName; - this.lastName = lastName; + this.firstname = firstname; + this.lastname = lastname; } public Long getId() { @@ -60,12 +68,16 @@ public String getPassword() { return password; } - public String getFirstName() { - return firstName; + public String getFirstname() { + return firstname; + } + + public String getLastname() { + return lastname; } - public String getLastName() { - return lastName; + public Role getRole() { + return role == null ? Role.CUSTOMER : role; } public void setId(Long id) { @@ -80,11 +92,15 @@ public void setPassword(String password) { this.password = password; } - public void setFirstName(String firstName) { - this.firstName = firstName; + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; } - public void setLastName(String lastName) { - this.lastName = lastName; + public void setRole(Role role) { + this.role = role == null ? Role.CUSTOMER : role; } -} \ No newline at end of file +} diff --git a/351004/Halukha/src/main/java/com/example/lab/repository/MarkerRepository.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/repository/MarkerRepository.java similarity index 59% rename from 351004/Halukha/src/main/java/com/example/lab/repository/MarkerRepository.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/repository/MarkerRepository.java index 3f25a72a8..1d08d31df 100644 --- a/351004/Halukha/src/main/java/com/example/lab/repository/MarkerRepository.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/repository/MarkerRepository.java @@ -1,8 +1,8 @@ -package com.example.lab.repository; +package com.example.lab.publisher.repository; import org.springframework.data.jpa.repository.JpaRepository; -import com.example.lab.model.Marker; +import com.example.lab.publisher.model.Marker; public interface MarkerRepository extends JpaRepository { } diff --git a/351004/Halukha/src/main/java/com/example/lab/repository/NewsRepository.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/repository/NewsRepository.java similarity index 59% rename from 351004/Halukha/src/main/java/com/example/lab/repository/NewsRepository.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/repository/NewsRepository.java index 3401ea168..3a0d45fa7 100644 --- a/351004/Halukha/src/main/java/com/example/lab/repository/NewsRepository.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/repository/NewsRepository.java @@ -1,8 +1,8 @@ -package com.example.lab.repository; +package com.example.lab.publisher.repository; import org.springframework.data.jpa.repository.JpaRepository; -import com.example.lab.model.News; +import com.example.lab.publisher.model.News; public interface NewsRepository extends JpaRepository { } diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/repository/UserRepository.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/repository/UserRepository.java new file mode 100644 index 000000000..8bef695ad --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.example.lab.publisher.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.lab.publisher.model.User; + +public interface UserRepository extends JpaRepository { + Optional findByLogin(String login); +} diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/AuthController.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/AuthController.java new file mode 100644 index 000000000..33391f704 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/AuthController.java @@ -0,0 +1,81 @@ +package com.example.lab.publisher.security; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +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.RestController; + +import com.example.lab.publisher.exception.GlobalExceptionHandler.ErrorResponse; +import com.example.lab.publisher.model.User; +import com.example.lab.publisher.repository.UserRepository; +import com.example.lab.publisher.security.AuthDtos.CurrentUserResponse; +import com.example.lab.publisher.security.AuthDtos.LoginRequest; +import com.example.lab.publisher.security.AuthDtos.RegisterRequest; +import com.example.lab.publisher.security.AuthDtos.TokenResponse; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api/v2.0") +public class AuthController { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtService jwtService; + + public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder, + AuthenticationManager authenticationManager, JwtService jwtService) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.authenticationManager = authenticationManager; + this.jwtService = jwtService; + } + + @PostMapping("/users") + public ResponseEntity register(@Valid @RequestBody RegisterRequest req) { + if (userRepository.findByLogin(req.getLogin()).isPresent()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new ErrorResponse(40301, "Duplicate entry")); + } + + User user = new User(); + user.setLogin(req.getLogin()); + user.setPassword(passwordEncoder.encode(req.getPassword())); + user.setFirstname(req.getFirstname()); + user.setLastname(req.getLastname()); + user.setRole(req.getRole()); + + User saved = userRepository.save(user); + return ResponseEntity.status(HttpStatus.CREATED) + .body(new CurrentUserResponse(saved.getId(), saved.getLogin(), saved.getFirstname(), + saved.getLastname(), saved.getRole())); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest req) { + Authentication auth = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(req.getLogin(), req.getPassword())); + SecurityContextHolder.getContext().setAuthentication(auth); + + User user = userRepository.findByLogin(req.getLogin()).orElseThrow(); + String token = jwtService.issueToken(user.getLogin(), user.getRole() == null ? Role.CUSTOMER : user.getRole()); + return ResponseEntity.ok(new TokenResponse(token)); + } + + @GetMapping("/users/me") + public ResponseEntity me() { + String login = SecurityContextHolder.getContext().getAuthentication().getName(); + User user = userRepository.findByLogin(login).orElseThrow(); + return ResponseEntity + .ok(new CurrentUserResponse(user.getId(), user.getLogin(), user.getFirstname(), user.getLastname(), + user.getRole() == null ? Role.CUSTOMER : user.getRole())); + } +} diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/AuthDtos.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/AuthDtos.java new file mode 100644 index 000000000..a89e26b48 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/AuthDtos.java @@ -0,0 +1,145 @@ +package com.example.lab.publisher.security; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class AuthDtos { + private AuthDtos() { + } + + public static class RegisterRequest { + @NotBlank + @Size(min = 2, max = 64) + private final String login; + + @NotBlank + @Size(min = 8, max = 128) + private final String password; + + @NotBlank + @Size(min = 2, max = 64) + private final String firstname; + + @NotBlank + @Size(min = 2, max = 64) + private final String lastname; + + private final Role role; + + @JsonCreator + public RegisterRequest( + @JsonProperty("login") String login, + @JsonProperty("password") String password, + @JsonProperty("firstname") String firstname, + @JsonProperty("lastname") String lastname, + @JsonProperty("role") Role role) { + this.login = login; + this.password = password; + this.firstname = firstname; + this.lastname = lastname; + this.role = role == null ? Role.CUSTOMER : role; + } + + public String getLogin() { + return login; + } + + public String getPassword() { + return password; + } + + public String getFirstname() { + return firstname; + } + + public String getLastname() { + return lastname; + } + + public Role getRole() { + return role; + } + } + + public static class LoginRequest { + @NotBlank + private final String login; + + @NotBlank + private final String password; + + @JsonCreator + public LoginRequest( + @JsonProperty("login") String login, + @JsonProperty("password") String password) { + this.login = login; + this.password = password; + } + + public String getLogin() { + return login; + } + + public String getPassword() { + return password; + } + } + + public static class TokenResponse { + private final String access_token; + private final String token_type; + + public TokenResponse(String accessToken) { + this.access_token = accessToken; + this.token_type = "Bearer"; + } + + public String getAccess_token() { + return access_token; + } + + public String getToken_type() { + return token_type; + } + } + + public static class CurrentUserResponse { + private final Long id; + private final String login; + private final String firstname; + private final String lastname; + private final Role role; + + public CurrentUserResponse(Long id, String login, String firstname, String lastname, Role role) { + this.id = id; + this.login = login; + this.firstname = firstname; + this.lastname = lastname; + this.role = role; + } + + public Long getId() { + return id; + } + + public String getLogin() { + return login; + } + + public String getFirstname() { + return firstname; + } + + public String getLastname() { + return lastname; + } + + public Role getRole() { + return role; + } + } +} + diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/JwtAuthFilter.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/JwtAuthFilter.java new file mode 100644 index 000000000..225aa396a --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/JwtAuthFilter.java @@ -0,0 +1,67 @@ +package com.example.lab.publisher.security; + +import java.io.IOException; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + + public JwtAuthFilter(JwtService jwtService, UserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String auth = request.getHeader(HttpHeaders.AUTHORIZATION); + if (auth == null || !auth.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = auth.substring("Bearer ".length()).trim(); + if (token.isEmpty() || SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } + + try { + Jws jws = jwtService.parse(token); + String login = jws.getPayload().getSubject(); + if (login == null || login.isBlank()) { + filterChain.doFilter(request, response); + return; + } + + UserDetails userDetails = userDetailsService.loadUserByUsername(login); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + // invalid token -> leave unauthenticated, entrypoint will handle if endpoint requires auth + } + + filterChain.doFilter(request, response); + } +} + diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/JwtService.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/JwtService.java new file mode 100644 index 000000000..9b858142d --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/JwtService.java @@ -0,0 +1,50 @@ +package com.example.lab.publisher.security; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; + +@Service +public class JwtService { + + private final SecretKey key; + private final long ttlSeconds; + + public JwtService( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.ttlSeconds:3600}") long ttlSeconds) { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.ttlSeconds = ttlSeconds; + } + + public String issueToken(String login, Role role) { + Instant now = Instant.now(); + Instant exp = now.plusSeconds(ttlSeconds); + + return Jwts.builder() + .subject(login) + .issuedAt(Date.from(now)) + .expiration(Date.from(exp)) + .claim("role", role.name()) + .signWith(key) + .compact(); + } + + public Jws parse(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + } +} + diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/OwnershipService.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/OwnershipService.java new file mode 100644 index 000000000..c1c4327a9 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/OwnershipService.java @@ -0,0 +1,46 @@ +package com.example.lab.publisher.security; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import com.example.lab.publisher.model.News; +import com.example.lab.publisher.model.User; +import com.example.lab.publisher.repository.NewsRepository; +import com.example.lab.publisher.repository.UserRepository; + +@Service("ownership") +public class OwnershipService { + + private final UserRepository userRepository; + private final NewsRepository newsRepository; + + public OwnershipService(UserRepository userRepository, NewsRepository newsRepository) { + this.userRepository = userRepository; + this.newsRepository = newsRepository; + } + + public boolean canAccessUser(Long id) { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) + return false; + + return userRepository.findByLogin(auth.getName()) + .map(user -> user.getId().equals(id)) + .orElse(false); + } + + public boolean canModifyNews(Long newsId) { + String login = SecurityContextHolder.getContext().getAuthentication().getName(); + User user = userRepository.findByLogin(login).orElse(null); + if (user == null || user.getId() == null) { + return false; + } + News news = newsRepository.findById(newsId).orElse(null); + return news != null && user.getId().equals(news.getUserId()); + } + + public Long currentUserId() { + String login = SecurityContextHolder.getContext().getAuthentication().getName(); + return userRepository.findByLogin(login).map(User::getId).orElse(null); + } +} diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/Role.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/Role.java new file mode 100644 index 000000000..1e0f68962 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/Role.java @@ -0,0 +1,7 @@ +package com.example.lab.publisher.security; + +public enum Role { + ADMIN, + CUSTOMER +} + diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/SecurityConfig.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/SecurityConfig.java new file mode 100644 index 000000000..ccfc52493 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/SecurityConfig.java @@ -0,0 +1,115 @@ +package com.example.lab.publisher.security; + +import java.io.IOException; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpMethod; + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder(); + return new PasswordEncoder() { + @Override + public String encode(CharSequence rawPassword) { + return bcrypt.encode(rawPassword); + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + if (encodedPassword == null) { + return false; + } + // backward compatibility: allow plain-text passwords created via v1 + if (!encodedPassword.startsWith("$2a$") && !encodedPassword.startsWith("$2b$") && !encodedPassword.startsWith("$2y$")) { + return rawPassword != null && encodedPassword.contentEquals(rawPassword); + } + return bcrypt.matches(rawPassword, encodedPassword); + } + }; + } + + @Bean + public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder encoder) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(encoder); + return new ProviderManager(provider); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter, ObjectMapper objectMapper) + throws Exception { + + http.csrf(csrf -> csrf.disable()); + http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http.exceptionHandling(eh -> eh + .authenticationEntryPoint((request, response, authException) -> writeError(response, objectMapper, 40101, + "Unauthorized")) + .accessDeniedHandler(accessDeniedHandler(objectMapper))); + + http.authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1.0/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v2.0/login").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v2.0/users").permitAll() + .requestMatchers("/api/v2.0/**").authenticated() + .anyRequest().permitAll()); + + http.httpBasic(basic -> basic.disable()); + http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + private AccessDeniedHandler accessDeniedHandler(ObjectMapper objectMapper) { + return (request, response, accessDeniedException) -> writeError(response, objectMapper, 40301, "Forbidden"); + } + + private void writeError(HttpServletResponse response, ObjectMapper objectMapper, int code, String message) + throws IOException { + response.setStatus(code / 100); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), new ErrorBody(code, message)); + } + + public static class ErrorBody { + private final int errorCode; + private final String errorMessage; + + public ErrorBody(int errorCode, String errorMessage) { + this.errorCode = errorCode; + this.errorMessage = errorMessage; + } + + public int getErrorCode() { + return errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + } +} + diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/SecurityUserDetailsService.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/SecurityUserDetailsService.java new file mode 100644 index 000000000..c12f49b6d --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/security/SecurityUserDetailsService.java @@ -0,0 +1,36 @@ +package com.example.lab.publisher.security; + +import java.util.List; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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; + +import com.example.lab.publisher.model.User; +import com.example.lab.publisher.repository.UserRepository; + +@Service +public class SecurityUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public SecurityUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByLogin(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + String role = user.getRole() == null ? Role.CUSTOMER.name() : user.getRole().name(); + return org.springframework.security.core.userdetails.User + .withUsername(user.getLogin()) + .password(user.getPassword()) + .authorities(List.of(new SimpleGrantedAuthority("ROLE_" + role))) + .build(); + } +} + diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/MarkerService.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/MarkerService.java new file mode 100644 index 000000000..d8b313464 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/MarkerService.java @@ -0,0 +1,76 @@ +package com.example.lab.publisher.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.stereotype.Service; + +import com.example.lab.publisher.dto.MarkerRequestTo; +import com.example.lab.publisher.dto.MarkerResponseTo; +import com.example.lab.publisher.exception.EntityNotFoundException; +import com.example.lab.publisher.mapper.MarkerMapper; +import com.example.lab.publisher.model.Marker; +import com.example.lab.publisher.repository.MarkerRepository; + +@Service +public class MarkerService { + + private final MarkerRepository markerRepository; + private final MarkerMapper mapper = MarkerMapper.INSTANCE; + + public MarkerService(MarkerRepository newsRepository) { + this.markerRepository = newsRepository; + } + + @Cacheable(cacheNames = "markers", key = "'all'") + public List getAllMarker() { + return markerRepository.findAll().stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } + + @Cacheable(cacheNames = "markers", key = "#id") + public MarkerResponseTo getMarkerById(Long id) { + return markerRepository.findById(id) + .map(mapper::toDto) + .orElseThrow(() -> new EntityNotFoundException("Marker not found", 40401)); + } + + @Caching(evict = { + @CacheEvict(cacheNames = "markers", key = "'all'") + }) + public MarkerResponseTo createMarker(MarkerRequestTo request) { + Marker news = mapper.toEntity(request); + Marker saved = markerRepository.save(news); + return mapper.toDto(saved); + } + + @Caching(put = { + @CachePut(cacheNames = "markers", key = "#id") + }, evict = { + @CacheEvict(cacheNames = "markers", key = "'all'") + }) + public MarkerResponseTo updateMarker(Long id, MarkerRequestTo request) { + Marker existing = markerRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Marker not found", 40401)); + Marker updated = mapper.updateEntity(request, existing); + updated.setId(id); + Marker saved = markerRepository.save(updated); + return mapper.toDto(saved); + } + + @Caching(evict = { + @CacheEvict(cacheNames = "markers", key = "#id"), + @CacheEvict(cacheNames = "markers", key = "'all'") + }) + public void deleteMarker(Long id) { + if (!markerRepository.existsById(id)) { + throw new EntityNotFoundException("Marker not found", 40401); + } + markerRepository.deleteById(id); + } +} diff --git a/351004/Halukha/src/main/java/com/example/lab/service/NewsService.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/NewsService.java similarity index 62% rename from 351004/Halukha/src/main/java/com/example/lab/service/NewsService.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/NewsService.java index 7bd7e419a..9e19ba1c7 100644 --- a/351004/Halukha/src/main/java/com/example/lab/service/NewsService.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/NewsService.java @@ -1,23 +1,27 @@ -package com.example.lab.service; +package com.example.lab.publisher.service; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; -import com.example.lab.dto.NewsRequestTo; -import com.example.lab.dto.NewsResponseTo; -import com.example.lab.dto.UserResponseTo; -import com.example.lab.exception.EntityNotFoundException; -import com.example.lab.mapper.NewsMapper; -import com.example.lab.mapper.UserMapper; -import com.example.lab.model.Marker; -import com.example.lab.model.News; -import com.example.lab.model.User; -import com.example.lab.repository.MarkerRepository; -import com.example.lab.repository.NewsRepository; -import com.example.lab.repository.UserRepository; +import com.example.lab.publisher.dto.NewsRequestTo; +import com.example.lab.publisher.dto.NewsResponseTo; +import com.example.lab.publisher.dto.UserResponseTo; +import com.example.lab.publisher.exception.EntityNotFoundException; +import com.example.lab.publisher.mapper.NewsMapper; +import com.example.lab.publisher.mapper.UserMapper; +import com.example.lab.publisher.model.Marker; +import com.example.lab.publisher.model.News; +import com.example.lab.publisher.model.User; +import com.example.lab.publisher.repository.MarkerRepository; +import com.example.lab.publisher.repository.NewsRepository; +import com.example.lab.publisher.repository.UserRepository; @Service public class NewsService { @@ -35,18 +39,24 @@ public NewsService(MarkerRepository markerRepository, NewsRepository newsReposit this.userRepository = userRepository; } + @Cacheable(cacheNames = "news", key = "'all'") public List getAllNews() { return newsRepository.findAll().stream() .map(mapper::toDto) .collect(Collectors.toList()); } + @Cacheable(cacheNames = "news", key = "#id") public NewsResponseTo getNewsById(Long id) { return newsRepository.findById(id) .map(mapper::toDto) .orElseThrow(() -> new EntityNotFoundException("News not found", 40401)); } + @Caching(evict = { + @CacheEvict(cacheNames = "news", key = "'all'"), + @CacheEvict(cacheNames = "markers", key = "'all'", condition = "#request.markers != null && !#request.markers.isEmpty()") + }) public NewsResponseTo createNews(NewsRequestTo request) { if (!userRepository.existsById(request.getUserId())) { throw new EntityNotFoundException("User not found", 40401); @@ -60,6 +70,13 @@ public NewsResponseTo createNews(NewsRequestTo request) { return mapper.toDto(saved); } + @Caching(put = { + @CachePut(cacheNames = "news", key = "#id") + }, evict = { + @CacheEvict(cacheNames = "news", key = "'all'"), + @CacheEvict(cacheNames = "newsUser", key = "#id"), + @CacheEvict(cacheNames = "markers", key = "'all'", condition = "#request.markers != null && !#request.markers.isEmpty()") + }) public NewsResponseTo updateNews(Long id, NewsRequestTo request) { News existing = newsRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("News not found", 40401)); @@ -85,6 +102,12 @@ private List resolveMarkers(List markerNames) { }).collect(Collectors.toList()); } + @Caching(evict = { + @CacheEvict(cacheNames = "news", key = "#id"), + @CacheEvict(cacheNames = "news", key = "'all'"), + @CacheEvict(cacheNames = "newsUser", key = "#id"), + @CacheEvict(cacheNames = "markers", key = "'all'") + }) public void deleteNews(Long id) { if (!newsRepository.existsById(id)) { throw new EntityNotFoundException("News not found", 40401); @@ -97,6 +120,7 @@ public void deleteNews(Long id) { newsRepository.deleteById(id); } + @Cacheable(cacheNames = "newsUser", key = "#newsId") public UserResponseTo getUserByNewsId(Long newsId) { News news = newsRepository.findById(newsId) .orElseThrow(() -> new EntityNotFoundException("News not found", 40401)); diff --git a/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/PostRedisCacheService.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/PostRedisCacheService.java new file mode 100644 index 000000000..d0140c2e2 --- /dev/null +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/PostRedisCacheService.java @@ -0,0 +1,63 @@ +package com.example.lab.publisher.service; + +import java.time.Duration; + +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Service +public class PostRedisCacheService { + + private static final Duration POST_TTL = Duration.ofMinutes(5); + + private final StringRedisTemplate redis; + private final ObjectMapper objectMapper; + + public PostRedisCacheService(StringRedisTemplate redis, ObjectMapper objectMapper) { + this.redis = redis; + this.objectMapper = objectMapper; + } + + private String keyById(Long id) { + return "posts:" + id; + } + + public Object get(Long id) { + try { + String json = redis.opsForValue().get(keyById(id)); + if (json == null) { + return null; + } + return objectMapper.readValue(json, Object.class); + } catch (DataAccessException e) { + return null; + } catch (JsonProcessingException e) { + return null; + } + } + + public void put(Long id, Object value) { + try { + String json = objectMapper.writeValueAsString(value); + redis.opsForValue().set(keyById(id), json, POST_TTL); + } catch (DataAccessException e) { + // skip cache on redis issues + } catch (JsonProcessingException e) { + // skip cache on serialization issues + } + } + + public void evict(Long id) { + try { + redis.delete(keyById(id)); + } catch (DataAccessException e) { + // skip + } + } +} + diff --git a/351004/Halukha/src/main/java/com/example/lab/service/UserService.java b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/UserService.java similarity index 59% rename from 351004/Halukha/src/main/java/com/example/lab/service/UserService.java rename to 351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/UserService.java index e6bc79526..52c2823c0 100644 --- a/351004/Halukha/src/main/java/com/example/lab/service/UserService.java +++ b/351004/Halukha/publisher/src/main/java/com/example/lab/publisher/service/UserService.java @@ -1,16 +1,20 @@ -package com.example.lab.service; +package com.example.lab.publisher.service; import java.util.List; import java.util.stream.Collectors; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.stereotype.Service; -import com.example.lab.dto.UserRequestTo; -import com.example.lab.dto.UserResponseTo; -import com.example.lab.exception.EntityNotFoundException; -import com.example.lab.mapper.UserMapper; -import com.example.lab.model.User; -import com.example.lab.repository.UserRepository; +import com.example.lab.publisher.dto.UserRequestTo; +import com.example.lab.publisher.dto.UserResponseTo; +import com.example.lab.publisher.exception.EntityNotFoundException; +import com.example.lab.publisher.mapper.UserMapper; +import com.example.lab.publisher.model.User; +import com.example.lab.publisher.repository.UserRepository; @Service public class UserService { @@ -22,24 +26,34 @@ public UserService(UserRepository userRepository) { this.userRepository = userRepository; } + @Cacheable(cacheNames = "users", key = "'all'") public List getAllUsers() { return userRepository.findAll().stream() .map(mapper::toDto) .collect(Collectors.toList()); } + @Cacheable(cacheNames = "users", key = "#id") public UserResponseTo getUserById(Long id) { return userRepository.findById(id) .map(mapper::toDto) .orElseThrow(() -> new EntityNotFoundException("User not found", 40401)); } + @Caching(evict = { + @CacheEvict(cacheNames = "users", key = "'all'") + }) public UserResponseTo createUser(UserRequestTo request) { User user = mapper.toEntity(request); User saved = userRepository.save(user); return mapper.toDto(saved); } + @Caching(put = { + @CachePut(cacheNames = "users", key = "#id") + }, evict = { + @CacheEvict(cacheNames = "users", key = "'all'") + }) public UserResponseTo updateUser(Long id, UserRequestTo request) { User existing = userRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("User not found", 40401)); @@ -49,10 +63,14 @@ public UserResponseTo updateUser(Long id, UserRequestTo request) { return mapper.toDto(saved); } + @Caching(evict = { + @CacheEvict(cacheNames = "users", key = "#id"), + @CacheEvict(cacheNames = "users", key = "'all'") + }) public void deleteUser(Long id) { if (!userRepository.existsById(id)) { throw new EntityNotFoundException("User not found", 40401); } userRepository.deleteById(id); } -} \ No newline at end of file +} diff --git a/351004/Halukha/publisher/src/main/resources/application.yaml b/351004/Halukha/publisher/src/main/resources/application.yaml new file mode 100644 index 000000000..ca7419e9b --- /dev/null +++ b/351004/Halukha/publisher/src/main/resources/application.yaml @@ -0,0 +1,50 @@ +server: + port: 24110 + +spring: + application: + name: publisher-service + + datasource: + url: jdbc:postgresql://localhost:5432/distcomp + username: postgres + password: postgres + driver-class-name: org.postgresql.Driver + + data: + redis: + host: localhost + port: 6379 + + kafka: + bootstrap-servers: localhost:9092 + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + properties: + spring.json.type.mapping: message:com.example.lab.publisher.dto.KafkaMessage + consumer: + group-id: publisher-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" + spring.json.type.mapping: message:com.example.lab.publisher.dto.KafkaMessage + template: + default-topic: news-topic + +app: + jwt: + secret: "change-me-change-me-change-me-change-me" + ttlSeconds: 3600 + + jpa: + database: postgresql + database-platform: org.hibernate.dialect.PostgreSQLDialect + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + \ No newline at end of file diff --git a/351004/Halukha/publisher/src/test/java/com/example/lab/LabApplicationTests.java b/351004/Halukha/publisher/src/test/java/com/example/lab/LabApplicationTests.java new file mode 100644 index 000000000..74eb19fa9 --- /dev/null +++ b/351004/Halukha/publisher/src/test/java/com/example/lab/LabApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.lab; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class LabApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/351004/Halukha/publisher/src/test/java/com/example/lab/test/BaseIntegrationTest.java b/351004/Halukha/publisher/src/test/java/com/example/lab/test/BaseIntegrationTest.java new file mode 100644 index 000000000..430104d27 --- /dev/null +++ b/351004/Halukha/publisher/src/test/java/com/example/lab/test/BaseIntegrationTest.java @@ -0,0 +1,29 @@ +package com.example.lab.test; + +import io.restassured.RestAssured; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class BaseIntegrationTest { + + @LocalServerPort + protected int port; + + @BeforeAll + static void setUpRestAssured() { + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @DynamicPropertySource + static void configureBaseUrl(DynamicPropertyRegistry registry) { + registry.add("server.port", () -> 0); + } + + protected String getBaseUrl() { + return "http://localhost:" + port; + } +} \ No newline at end of file diff --git a/351004/Halukha/publisher/src/test/java/com/example/lab/test/MarkerControllerIntegrationTest.java b/351004/Halukha/publisher/src/test/java/com/example/lab/test/MarkerControllerIntegrationTest.java new file mode 100644 index 000000000..1e34ed716 --- /dev/null +++ b/351004/Halukha/publisher/src/test/java/com/example/lab/test/MarkerControllerIntegrationTest.java @@ -0,0 +1,149 @@ +package com.example.lab.test; + +import io.restassured.http.ContentType; +import org.junit.jupiter.api.*; +import org.springframework.http.HttpStatus; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class MarkerControllerIntegrationTest extends BaseIntegrationTest { + + private static Long markerId; + + @Test + @Order(1) + @DisplayName("STEP 1: CREATE MARKER - Should return 201 Created") + void createMarker() { + String payload = """ + { + "name": "Test Marker" + } + """; + + markerId = given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(payload) + .when() + .post("/api/v1.0/markers") + .then() + .statusCode(HttpStatus.CREATED.value()) + .contentType(ContentType.JSON) + .body("id", notNullValue()) + .body("name", equalTo("Test Marker")) + .extract() + .jsonPath() + .getLong("id"); + + Assertions.assertNotNull(markerId); + } + + @Test + @Order(2) + @DisplayName("STEP 2: GET MARKER BY ID - Should return 200 OK") + void getMarkerById() { + given() + .baseUri(getBaseUrl()) + .when() + .get("/api/v1.0/markers/{id}", markerId) + .then() + .statusCode(HttpStatus.OK.value()) + .contentType(ContentType.JSON) + .body("id", equalTo(markerId.intValue())) + .body("name", equalTo("Test Marker")); + } + + @Test + @Order(3) + @DisplayName("STEP 3: UPDATE MARKER - Should return 200 OK") + void updateMarker() { + String payload = """ + { + "id": %d, + "name": "Updated Marker" + } + """.formatted(markerId); + + given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(payload) + .when() + .put("/api/v1.0/markers/{id}", markerId) + .then() + .statusCode(HttpStatus.OK.value()) + .contentType(ContentType.JSON) + .body("id", equalTo(markerId.intValue())) + .body("name", equalTo("Updated Marker")); + } + + @Test + @Order(4) + @DisplayName("STEP 4: DELETE MARKER - Should return 204 No Content") + void deleteMarker() { + given() + .baseUri(getBaseUrl()) + .when() + .delete("/api/v1.0/markers/{id}", markerId) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @Test + @Order(5) + @DisplayName("STEP 5: GET DELETED MARKER - Should return 404 Not Found") + void getDeletedMarker() { + given() + .baseUri(getBaseUrl()) + .when() + .get("/api/v1.0/markers/{id}", markerId) + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .contentType(ContentType.JSON) + .body("errorCode", equalTo(40401)); + } + + @Test + @Order(6) + @DisplayName("NEGATIVE: CREATE MARKER WITH INVALID DATA - Should return 400 Bad Request") + void createMarkerWithInvalidData() { + String payload = """ + { + "name": "" + } + """; + + given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(payload) + .when() + .post("/api/v1.0/markers") + .then() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @Order(7) + @DisplayName("NEGATIVE: UPDATE NON-EXISTENT MARKER - Should return 404 Not Found") + void updateNonExistentMarker() { + String payload = """ + { + "id": 999999, + "name": "Non-existent" + } + """; + + given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(payload) + .when() + .put("/api/v1.0/markers/{id}", 999999) + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .body("errorCode", equalTo(40401)); + } +} \ No newline at end of file diff --git a/351004/Halukha/publisher/src/test/java/com/example/lab/test/NewsControllerIntegrationTest.java b/351004/Halukha/publisher/src/test/java/com/example/lab/test/NewsControllerIntegrationTest.java new file mode 100644 index 000000000..3b7ae5d1d --- /dev/null +++ b/351004/Halukha/publisher/src/test/java/com/example/lab/test/NewsControllerIntegrationTest.java @@ -0,0 +1,176 @@ +package com.example.lab.test; + +import io.restassured.http.ContentType; +import org.junit.jupiter.api.*; +import org.springframework.http.HttpStatus; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class NewsControllerIntegrationTest extends BaseIntegrationTest { + + private static Long newsId; + private static Long userId; + + @BeforeEach + void createTestUser() { + // Создаем пользователя для новости + String userPayload = """ + { + "login": "news_author", + "password": "password123", + "firstname": "Author", + "lastname": "Name" + } + """; + + userId = given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(userPayload) + .when() + .post("/api/v1.0/users") + .then() + .statusCode(HttpStatus.CREATED.value()) + .extract() + .jsonPath() + .getLong("id"); + } + + @Test + @Order(1) + @DisplayName("STEP 1: CREATE NEWS - Should return 201 Created") + void createNews() { + String payload = """ + { + "title": "Breaking News", + "content": "This is important news!", + "userId": %d + } + """.formatted(userId); + + newsId = given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(payload) + .when() + .post("/api/v1.0/news") + .then() + .statusCode(HttpStatus.CREATED.value()) + .contentType(ContentType.JSON) + .body("id", notNullValue()) + .body("title", equalTo("Breaking News")) + .body("content", equalTo("This is important news!")) + .extract() + .jsonPath() + .getLong("id"); + + Assertions.assertNotNull(newsId); + } + + @Test + @Order(2) + @DisplayName("STEP 2: GET NEWS BY ID - Should return 200 OK") + void getNewsById() { + given() + .baseUri(getBaseUrl()) + .when() + .get("/api/v1.0/news/{id}", newsId) + .then() + .statusCode(HttpStatus.OK.value()) + .contentType(ContentType.JSON) + .body("id", equalTo(newsId.intValue())) + .body("title", equalTo("Breaking News")) + .body("content", equalTo("This is important news!")); + } + + @Test + @Order(4) + @DisplayName("STEP 4: UPDATE NEWS - Should return 200 OK") + void updateNews() { + String payload = """ + { + "id": %d, + "title": "Updated News", + "content": "Updated content!", + "userId": %d + } + """.formatted(newsId, userId); + + given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(payload) + .when() + .put("/api/v1.0/news/{id}", newsId) + .then() + .statusCode(HttpStatus.OK.value()) + .contentType(ContentType.JSON) + .body("id", equalTo(newsId.intValue())) + .body("title", equalTo("Updated News")) + .body("content", equalTo("Updated content!")); + } + + @Test + @Order(5) + @DisplayName("STEP 5: DELETE NEWS - Should return 204 No Content") + void deleteNews() { + given() + .baseUri(getBaseUrl()) + .when() + .delete("/api/v1.0/news/{id}", newsId) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @Test + @Order(6) + @DisplayName("STEP 6: GET DELETED NEWS - Should return 404 Not Found") + void getDeletedNews() { + given() + .baseUri(getBaseUrl()) + .when() + .get("/api/v1.0/news/{id}", newsId) + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .contentType(ContentType.JSON) + .body("errorCode", equalTo(40401)); + } + + @Test + @Order(7) + @DisplayName("STEP 7: GET USER BY DELETED NEWS ID - Should return 404 Not Found") + void getUserByDeletedNewsId() { + given() + .baseUri(getBaseUrl()) + .when() + .get("/api/v1.0/news/user/{id}", newsId) + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .body("errorCode", equalTo(40401)); + } + + @Test + @Order(8) + @DisplayName("NEGATIVE: CREATE NEWS WITH INVALID USER ID - Should return 404 Not Found") + void createNewsWithInvalidUserId() { + String payload = """ + { + "title": "Invalid News", + "content": "This should fail", + "userId": 999999 + } + """; + + given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(payload) + .when() + .post("/api/v1.0/news") + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .body("errorCode", equalTo(40401)); + } +} \ No newline at end of file diff --git a/351004/Halukha/publisher/src/test/java/com/example/lab/test/SecurityV2IntegrationTest.java b/351004/Halukha/publisher/src/test/java/com/example/lab/test/SecurityV2IntegrationTest.java new file mode 100644 index 000000000..b3a382493 --- /dev/null +++ b/351004/Halukha/publisher/src/test/java/com/example/lab/test/SecurityV2IntegrationTest.java @@ -0,0 +1,86 @@ +package com.example.lab.test; + +import static org.hamcrest.Matchers.notNullValue; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +import static io.restassured.RestAssured.given; +import io.restassured.http.ContentType; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class SecurityV2IntegrationTest { + + @LocalServerPort + int port; + + @Test + void v1_stays_public() { + given() + .port(port) + .when() + .get("/api/v1.0/users") + .then() + .statusCode(200); + } + + @Test + void v2_requires_auth() { + given() + .port(port) + .when() + .get("/api/v2.0/news") + .then() + .statusCode(401); + } + + @Test + void registration_and_login_issue_jwt() { + String login = "sec_user_" + System.currentTimeMillis(); + + given() + .port(port) + .contentType(ContentType.JSON) + .body(""" + { + "login": "%s", + "password": "password123", + "firstname": "John", + "lastname": "Doe", + "role": "CUSTOMER" + } + """.formatted(login)) + .when() + .post("/api/v2.0/users") + .then() + .statusCode(201); + + String token = given() + .port(port) + .contentType(ContentType.JSON) + .body(""" + { + "login": "%s", + "password": "password123" + } + """.formatted(login)) + .when() + .post("/api/v2.0/login") + .then() + .statusCode(200) + .body("access_token", notNullValue()) + .extract() + .path("access_token"); + + given() + .port(port) + .header("Authorization", "Bearer " + token) + .when() + .get("/api/v2.0/users/me") + .then() + .statusCode(200) + .body("login", notNullValue()) + .body("role", notNullValue()); + } +} + diff --git a/351004/Halukha/publisher/src/test/java/com/example/lab/test/UserControllerIntegrationTest.java b/351004/Halukha/publisher/src/test/java/com/example/lab/test/UserControllerIntegrationTest.java new file mode 100644 index 000000000..cef275a4f --- /dev/null +++ b/351004/Halukha/publisher/src/test/java/com/example/lab/test/UserControllerIntegrationTest.java @@ -0,0 +1,118 @@ +package com.example.lab.test; + +import io.restassured.http.ContentType; +import org.junit.jupiter.api.*; +import org.springframework.http.HttpStatus; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class UserControllerIntegrationTest extends BaseIntegrationTest { + + private static Long userId; + + @Test + @Order(1) + @DisplayName("STEP 1: CREATE USER - Should return 201 Created") + void createUser() { + String payload = """ + { + "login": "test_user", + "password": "secure123", + "firstname": "John", + "lastname": "Doe" + } + """; + + userId = given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(payload) + .when() + .post("/api/v1.0/users") + .then() + .statusCode(HttpStatus.CREATED.value()) + .contentType(ContentType.JSON) + .body("id", notNullValue()) + .body("login", equalTo("test_user")) + .body("firstname", equalTo("John")) + .body("lastname", equalTo("Doe")) + .extract() + .jsonPath() + .getLong("id"); + + Assertions.assertNotNull(userId); + } + + @Test + @Order(2) + @DisplayName("STEP 2: GET USER BY ID - Should return 200 OK") + void getUserById() { + given() + .baseUri(getBaseUrl()) + .when() + .get("/api/v1.0/users/{id}", userId) + .then() + .statusCode(HttpStatus.OK.value()) + .contentType(ContentType.JSON) + .body("id", equalTo(userId.intValue())) + .body("login", equalTo("test_user")) + .body("firstname", equalTo("John")); + } + + @Test + @Order(3) + @DisplayName("STEP 3: UPDATE USER - Should return 200 OK") + void updateUser() { + String payload = """ + { + "id": %d, + "login": "test_user", + "password": "newpassword456", + "firstname": "Jane", + "lastname": "Smith" + } + """.formatted(userId); + + given() + .baseUri(getBaseUrl()) + .contentType(ContentType.JSON) + .body(payload) + .when() + .put("/api/v1.0/users/{id}", userId) + .then() + .statusCode(HttpStatus.OK.value()) + .contentType(ContentType.JSON) + .body("id", equalTo(userId.intValue())) + .body("login", equalTo("test_user")) + .body("firstname", equalTo("Jane")) + .body("lastname", equalTo("Smith")); + } + + @Test + @Order(4) + @DisplayName("STEP 4: DELETE USER - Should return 204 No Content") + void deleteUser() { + given() + .baseUri(getBaseUrl()) + .when() + .delete("/api/v1.0/users/{id}", userId) + .then() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @Test + @Order(5) + @DisplayName("STEP 5: GET DELETED USER - Should return 404 Not Found") + void getDeletedUser() { + given() + .baseUri(getBaseUrl()) + .when() + .get("/api/v1.0/users/{id}", userId) + .then() + .statusCode(HttpStatus.NOT_FOUND.value()) + .contentType(ContentType.JSON) + .body("errorCode", equalTo(40401)); + } +} \ No newline at end of file diff --git a/351004/Halukha/src/main/java/com/example/lab/LabApplication.java b/351004/Halukha/src/main/java/com/example/lab/LabApplication.java deleted file mode 100644 index 145e3f3fa..000000000 --- a/351004/Halukha/src/main/java/com/example/lab/LabApplication.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.example.lab; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class LabApplication { - - public static void main(String[] args) { - SpringApplication.run(LabApplication.class, args); - } -} diff --git a/351004/Halukha/src/main/java/com/example/lab/repository/PostRepository.java b/351004/Halukha/src/main/java/com/example/lab/repository/PostRepository.java deleted file mode 100644 index 9feaeaf26..000000000 --- a/351004/Halukha/src/main/java/com/example/lab/repository/PostRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.lab.repository; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.example.lab.model.Post; - -public interface PostRepository extends JpaRepository { -} diff --git a/351004/Halukha/src/main/java/com/example/lab/repository/UserRepository.java b/351004/Halukha/src/main/java/com/example/lab/repository/UserRepository.java deleted file mode 100644 index 0e36a4dbb..000000000 --- a/351004/Halukha/src/main/java/com/example/lab/repository/UserRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.lab.repository; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.example.lab.model.User; - -public interface UserRepository extends JpaRepository { -} diff --git a/351004/Halukha/src/main/java/com/example/lab/service/MarkerService.java b/351004/Halukha/src/main/java/com/example/lab/service/MarkerService.java deleted file mode 100644 index 342ed0baa..000000000 --- a/351004/Halukha/src/main/java/com/example/lab/service/MarkerService.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.example.lab.service; - -import java.util.List; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Service; - -import com.example.lab.dto.MarkerRequestTo; -import com.example.lab.dto.MarkerResponseTo; -import com.example.lab.exception.EntityNotFoundException; -import com.example.lab.mapper.MarkerMapper; -import com.example.lab.model.Marker; -import com.example.lab.repository.MarkerRepository; - -@Service -public class MarkerService { - - private final MarkerRepository newsRepository; - private final MarkerMapper mapper = MarkerMapper.INSTANCE; - - public MarkerService(MarkerRepository newsRepository) { - this.newsRepository = newsRepository; - } - - public List getAllMarker() { - return newsRepository.findAll().stream() - .map(mapper::toDto) - .collect(Collectors.toList()); - } - - public MarkerResponseTo getMarkerById(Long id) { - return newsRepository.findById(id) - .map(mapper::toDto) - .orElseThrow(() -> new EntityNotFoundException("Marker not found", 40401)); - } - - public MarkerResponseTo createMarker(MarkerRequestTo request) { - Marker news = mapper.toEntity(request); - Marker saved = newsRepository.save(news); - return mapper.toDto(saved); - } - - public MarkerResponseTo updateMarker(Long id, MarkerRequestTo request) { - Marker existing = newsRepository.findById(id) - .orElseThrow(() -> new EntityNotFoundException("Marker not found", 40401)); - Marker updated = mapper.updateEntity(request, existing); - updated.setId(id); - Marker saved = newsRepository.save(updated); - return mapper.toDto(saved); - } - - public void deleteMarker(Long id) { - if (!newsRepository.existsById(id)) { - throw new EntityNotFoundException("Marker not found", 40401); - } - newsRepository.deleteById(id); - } -} diff --git a/351004/Halukha/src/main/java/com/example/lab/service/PostService.java b/351004/Halukha/src/main/java/com/example/lab/service/PostService.java deleted file mode 100644 index 8833d1365..000000000 --- a/351004/Halukha/src/main/java/com/example/lab/service/PostService.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.example.lab.service; - -import java.util.List; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Service; - -import com.example.lab.dto.PostRequestTo; -import com.example.lab.dto.PostResponseTo; -import com.example.lab.exception.EntityNotFoundException; -import com.example.lab.mapper.PostMapper; -import com.example.lab.model.News; -import com.example.lab.model.Post; -import com.example.lab.repository.NewsRepository; -import com.example.lab.repository.PostRepository; - -@Service -public class PostService { - - private final PostRepository postRepository; - private final NewsRepository newsRepository; - private final PostMapper mapper = PostMapper.INSTANCE; - - public PostService(PostRepository postRepository, NewsRepository newsRepository) { - this.postRepository = postRepository; - this.newsRepository = newsRepository; - } - - public List getAllPost() { - return postRepository.findAll().stream() - .map(mapper::toDto) - .collect(Collectors.toList()); - } - - public PostResponseTo getPostById(Long id) { - return postRepository.findById(id) - .map(mapper::toDto) - .orElseThrow(() -> new EntityNotFoundException("Post not found", 40401)); - } - - public PostResponseTo createPost(PostRequestTo request) { - if (!newsRepository.existsById(request.getNewsId())) { - throw new EntityNotFoundException("News not found", 40401); - } - Post news = mapper.toEntity(request); - Post saved = postRepository.save(news); - return mapper.toDto(saved); - } - - public PostResponseTo updatePost(Long id, PostRequestTo request) { - Post existing = postRepository.findById(id) - .orElseThrow(() -> new EntityNotFoundException("Post not found", 40401)); - Post updated = mapper.updateEntity(request, existing); - updated.setId(id); - Post saved = postRepository.save(updated); - return mapper.toDto(saved); - } - - public void deletePost(Long id) { - if (!postRepository.existsById(id)) { - throw new EntityNotFoundException("Post not found", 40401); - } - postRepository.deleteById(id); - } - - public List getAllPostByNewsId(Long userId) { - List news = newsRepository.findAll().stream() - .filter(news1 -> news1.getUserId().equals(userId)) - .collect(Collectors.toList()); - return postRepository.findAll().stream() - .filter(post -> news.stream().anyMatch(post1 -> post1.getId().equals(post.getNewsId()))) - .map(mapper::toDto) - .collect(Collectors.toList()); - } -} diff --git a/351004/Halukha/src/main/resources/application.properties b/351004/Halukha/src/main/resources/application.properties deleted file mode 100644 index eeee4ba03..000000000 --- a/351004/Halukha/src/main/resources/application.properties +++ /dev/null @@ -1,12 +0,0 @@ -spring.application.name=lab - -spring.datasource.url=jdbc:postgresql://localhost:5432/distcomp -spring.datasource.username=postgres -spring.datasource.password=postgres -spring.datasource.driver-class-name=org.postgresql.Driver - -spring.jpa.database=postgresql -spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true -spring.jpa.properties.hibernate.format_sql=true diff --git a/351004/Halukha/src/main/resources/application.yaml b/351004/Halukha/src/main/resources/application.yaml deleted file mode 100644 index 84eba713e..000000000 --- a/351004/Halukha/src/main/resources/application.yaml +++ /dev/null @@ -1,6 +0,0 @@ -server: - port: 24110 - -spring: - application: - name: task310-rest-api \ No newline at end of file