diff --git a/build.gradle b/build.gradle index c9e4f45..9623738 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,9 @@ dependencies { implementation 'org.mapstruct:mapstruct:1.5.5.Final' implementation 'commons-validator:commons-validator:1.7' + implementation group: 'com.google.cloud', name: 'google-cloud-vision', version: '3.55.0' + implementation group: 'com.google.api-client', name: 'google-api-client', version: '2.7.2' + implementation 'org.flywaydb:flyway-core:9.22.0' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.0' diff --git a/src/main/java/ru/soune/nocopy/configuration/RestTemplateConfig.java b/src/main/java/ru/soune/nocopy/configuration/RestTemplateConfig.java new file mode 100644 index 0000000..ab07422 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/configuration/RestTemplateConfig.java @@ -0,0 +1,22 @@ +package ru.soune.nocopy.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + RestTemplate restTemplate = new RestTemplate(); + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(30000); + requestFactory.setReadTimeout(60000); + restTemplate.setRequestFactory(requestFactory); + + return restTemplate; + } +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/GoogleVisionSearchResponse.java b/src/main/java/ru/soune/nocopy/dto/file/GoogleVisionSearchResponse.java new file mode 100644 index 0000000..a2fe685 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/GoogleVisionSearchResponse.java @@ -0,0 +1,68 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class GoogleVisionSearchResponse { + + @JsonProperty("bestGuessLabels") + private List bestGuessLabels; + + @JsonProperty("fullMatchingImages") + private List fullMatchingImages; + + @JsonProperty("visuallySimilarImages") + private List visuallySimilarImages; + + @JsonProperty("pagesWithMatchingImages") + private List pagesWithMatchingImages; + + @JsonProperty("partialMatchingImages") + private List partialMatchingImages; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class BestGuessLabel { + @JsonProperty("label") + private String label; + + @JsonProperty("languageCode") + private String languageCode; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ImageResult { + @JsonProperty("url") + private String url; + + @JsonProperty("score") + private Float score; + + @JsonProperty("height") + private Integer height; + + @JsonProperty("width") + private Integer width; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class PageResult { + @JsonProperty("url") + private String url; + + @JsonProperty("pageTitle") + private String pageTitle; + + @JsonProperty("fullMatchingImages") + private List fullMatchingImages; + + @JsonProperty("partialMatchingImages") + private List partialMatchingImages; + } +} diff --git a/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java b/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java index 3d13d4a..ad7d3ae 100644 --- a/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java +++ b/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java @@ -3,13 +3,14 @@ package ru.soune.nocopy.handler; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import ru.soune.nocopy.dto.BaseRequest; import ru.soune.nocopy.dto.BaseResponse; import ru.soune.nocopy.dto.MessageCode; +import ru.soune.nocopy.dto.file.GoogleVisionSearchResponse; import ru.soune.nocopy.dto.file.ImageSearchRequest; import ru.soune.nocopy.dto.file.YandexSearchResponse; +import ru.soune.nocopy.service.GoogleVisionSearchService; import ru.soune.nocopy.service.YandexSearchService; @Slf4j @@ -21,6 +22,8 @@ public class ImageFoundRequestHandler implements RequestHandler { private final YandexSearchService yandexSearchService; + private final GoogleVisionSearchService googleVisionSearchService; + @Override public BaseResponse handle(BaseRequest request) throws Exception { ImageSearchRequest imageSearchRequest = objectMapper.convertValue(request.getMessageBody(), @@ -30,6 +33,9 @@ public class ImageFoundRequestHandler implements RequestHandler { YandexSearchResponse response = yandexSearchService.searchByFileEntity(fileId); + //TODO uncommited when add billing +// GoogleVisionSearchResponse googleVisionSearchResponse = googleVisionSearchService.searchByFileEntity(fileId); + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(), response); } diff --git a/src/main/java/ru/soune/nocopy/service/GoogleVisionSearchService.java b/src/main/java/ru/soune/nocopy/service/GoogleVisionSearchService.java new file mode 100644 index 0000000..f0829a1 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/GoogleVisionSearchService.java @@ -0,0 +1,205 @@ +package ru.soune.nocopy.service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.vision.v1.*; +import com.google.protobuf.ByteString; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import ru.soune.nocopy.dto.BaseResponse; +import ru.soune.nocopy.dto.MessageCode; +import ru.soune.nocopy.dto.file.GoogleVisionSearchResponse; +import ru.soune.nocopy.entity.file.FileEntity; +import ru.soune.nocopy.exception.NotValidFieldException; +import ru.soune.nocopy.repository.FileEntityRepository; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleVisionSearchService { + private final FileEntityRepository fileEntityRepository; + + private final ObjectMapper objectMapper; + + private ImageAnnotatorClient visionClient; + + @PostConstruct + public void init() throws IOException { + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); + + initVisionClient(); + } + + private void initVisionClient() throws IOException { + try (InputStream credentialsStream = new ClassPathResource("config/google-service-account.json").getInputStream()) { + GoogleCredentials credentials = GoogleCredentials.fromStream(credentialsStream); + + ImageAnnotatorSettings settings = ImageAnnotatorSettings.newBuilder() + .setCredentialsProvider(() -> credentials) + .build(); + + this.visionClient = ImageAnnotatorClient.create(settings); + } + } + + public GoogleVisionSearchResponse searchByFileEntity(String fileId) throws IOException { + FileEntity fileEntity = fileEntityRepository.findById(fileId) + .orElseThrow(() -> { + throw new NotValidFieldException("File not found", new BaseResponse(20007, + MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(), + Map.of("fileId", fileId))); + }); + + if (!isImageFile(fileEntity)) { + throw new NotValidFieldException("File not image", new BaseResponse(20007, + MessageCode.INVALID_FIELD.getCode(), MessageCode.INVALID_FIELD.getDescription(), + Map.of("file_type", fileEntity.getMimeType()))); + } + + byte[] fileBytes; + try { + fileBytes = readFileFromDisk(fileEntity); + } catch (IOException e) { + throw new NotValidFieldException("File not found or cannot read file", new BaseResponse(20007, + MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(), + Map.of("fileId", fileId, + "filePath", fileEntity.getFilePath()))); + } + + return callGoogleVisionApi(fileBytes); + } + + private boolean isImageFile(FileEntity fileEntity) { + String mimeType = fileEntity.getMimeType(); + return mimeType != null && mimeType.startsWith("image"); + } + + private byte[] readFileFromDisk(FileEntity fileEntity) throws IOException { + Path filePath = Path.of(fileEntity.getFilePath()); + + if (!Files.exists(filePath) || !Files.isReadable(filePath)) { + throw new IOException("Cannot read file: " + fileEntity.getFilePath()); + } + + return Files.readAllBytes(filePath); + } + + private GoogleVisionSearchResponse callGoogleVisionApi(byte[] imageBytes) throws IOException { + ByteString imgBytes = ByteString.copyFrom(imageBytes); + Image image = Image.newBuilder() + .setContent(imgBytes) + .build(); + + Feature feature = Feature.newBuilder() + .setType(Feature.Type.WEB_DETECTION) + .setMaxResults(20) + .build(); + + AnnotateImageRequest request = AnnotateImageRequest.newBuilder() + .addFeatures(feature) + .setImage(image) + .build(); + + BatchAnnotateImagesResponse response = visionClient.batchAnnotateImages(List.of(request)); + + if (response.getResponsesCount() == 0) { + throw new IOException("Empty response from Google Vision API"); + } + + AnnotateImageResponse singleResponse = response.getResponses(0); + + if (singleResponse.hasError()) { + throw new IOException("Google Vision API error: " + singleResponse.getError().getMessage()); + } + + return convertToResponse(singleResponse.getWebDetection()); + } + + private GoogleVisionSearchResponse convertToResponse(WebDetection webDetection) { + GoogleVisionSearchResponse response = new GoogleVisionSearchResponse(); + + if (webDetection.getBestGuessLabelsCount() > 0) { + response.setBestGuessLabels( + webDetection.getBestGuessLabelsList().stream() + .map(label -> { + GoogleVisionSearchResponse.BestGuessLabel dtoLabel = + new GoogleVisionSearchResponse.BestGuessLabel(); + dtoLabel.setLabel(label.getLabel()); + dtoLabel.setLanguageCode(label.getLanguageCode()); + return dtoLabel; + }) + .toList() + ); + } + + if (webDetection.getFullMatchingImagesCount() > 0) { + response.setFullMatchingImages( + webDetection.getFullMatchingImagesList().stream() + .map(img -> { + GoogleVisionSearchResponse.ImageResult dtoImg = + new GoogleVisionSearchResponse.ImageResult(); + dtoImg.setUrl(img.getUrl()); + dtoImg.setScore(img.getScore()); + return dtoImg; + }) + .toList() + ); + } + + if (webDetection.getVisuallySimilarImagesCount() > 0) { + response.setVisuallySimilarImages( + webDetection.getVisuallySimilarImagesList().stream() + .map(img -> { + GoogleVisionSearchResponse.ImageResult dtoImg = + new GoogleVisionSearchResponse.ImageResult(); + dtoImg.setUrl(img.getUrl()); + dtoImg.setScore(img.getScore()); + return dtoImg; + }) + .toList() + ); + } + + if (webDetection.getPagesWithMatchingImagesCount() > 0) { + response.setPagesWithMatchingImages( + webDetection.getPagesWithMatchingImagesList().stream() + .map(page -> { + GoogleVisionSearchResponse.PageResult dtoPage = + new GoogleVisionSearchResponse.PageResult(); + dtoPage.setUrl(page.getUrl()); + dtoPage.setPageTitle(page.getPageTitle()); + return dtoPage; + }) + .toList() + ); + } + + if (webDetection.getPartialMatchingImagesCount() > 0) { + response.setPartialMatchingImages( + webDetection.getPartialMatchingImagesList().stream() + .map(img -> { + GoogleVisionSearchResponse.ImageResult dtoImg = + new GoogleVisionSearchResponse.ImageResult(); + dtoImg.setUrl(img.getUrl()); + dtoImg.setScore(img.getScore()); + return dtoImg; + }) + .toList() + ); + } + + return response; + } +} \ No newline at end of file diff --git a/src/main/resources/config/google-service-account.json b/src/main/resources/config/google-service-account.json new file mode 100644 index 0000000..2e50435 --- /dev/null +++ b/src/main/resources/config/google-service-account.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "ncp-1-483610", + "private_key_id": "94ea762d9a56a3362155ab33f8959bfec5ae8b42", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwjY9sYQfeqHwr\nqKgr8sw3Qfao/vzTa9wnmbZvrM6UJ3ukMkj08w50rP0udKxTRWnH6rpSQnD696GS\nY0/qrjEO1LeMdPJ+R0v8B+Z3PlpuQtfmxXALF4QbGQxeWg9NK7o4JM0kANkV80me\nxo6T7WnywQm1SfBIPZjwLBhkn7pfcdTDJFiZkH073yB6/tPsgd6TRQDmlvGsrGmq\nCf8E/DhxaFIHUpoUEGpxeuFPmvAicEWdURCtCXeiAxRQv+Gy0mP7uGGY6C3SeYfJ\n5PNLopgnN2ATCeIp1EjJnH28sAEvE0e/K/9z4ZThgh1mB1CaLkaQweq3YQWoiyGF\nfcoPpJ8LAgMBAAECggEABluSppx35BS9I/VW2P5NTFAbycok4JgpvWNTpoHajos9\ncJQ+/FMkWh9AnsOk0PrW7FQPkZbC6sawEs1wu0q6iYYvdedgNBvtKI5ARlsAdqgB\njlXdywh1wWQNhfhIRMcmVY89s+Yz6w5vwD/2Mm2COzOoXdbjyDYvo7ZyymzWEUnA\nwaxg7aF5cGC+iqf+l2Ym8XdY2ZavvyZ8RwgKfQGfcPuhMogg+H6au4yvXPBNxv0u\nxDCbOm2ezA4cLyKyFqi6S5KiGmk2LnCOoLrkIalmTQhnptFZofGs4ezuOvhZ6ZTi\nQVDayHzbBygmFzJn1wzw83IQ7QgMmWYoEjC+JcRlgQKBgQDg6U3QPxhdvX1dCzBj\nef6yUrIeJhiILvKnAzi6MUhaWP0mU6kcIIn8DS78ojML+leKh2R7JQFFrt87kHjK\npcaTC7wkgNCa6e3d+U95EIVUvPVrvQ5O4zIM7evTiPEeZhN9Dhuh0o3eeYxfIqZO\nsN0y8F/xPWdGW5CcdL7dcTOUkwKBgQDI9Qzr7poRaHM8jzS0M4Q4cVsYdC2P6KgI\nWD9Qbbl1rVi14YLt1FSd4w7U4YJ/kaWOrdtKklqAvM3oq5J0oEQekpixsxFvXdW+\ngJTzOoOq2lgCThBagwVKOFkAH/CwPkhFPwuVBgfVNiajbrcGF6i5VWzIHCXT+IDe\naSw64dqOqQKBgDp9EYpNTjXaeEaBCWVlLVIMZVunxotrwhiiotbwyAMOz05vRTQW\nVivg9c4nFCVSRf+1c/D+T5Vig5UG3hK9B6Xn0Fah1R3kJcKq+frey/2cYipRcO4c\n8UAhg0lwfFvOadUEnTT4/4HSlCmNZjhikDOWBS1ELZ5DY5j8V0JZFPPXAoGAXi0B\nLjw2dbwGbTYLk/ukljMBZvdjNtLolGiO22lghbaEIVCa5Ewij4+OLtO0LYabGL9/\nSnZF9ZkFwmlNjFxjMBSxfG2X2SIXflyR8V7Vv6btob7lyRUn0H2RsA5H5MB7bAA8\ntE0MNK5Y8zR6j19dEeXnwev3ClymQBT3xmx72WkCgYAEhx+piCTvD+2SFBweM3J4\ng6LefBXFWHSlftQF1Er5mI+5kr4i3M5d6zh8lKcLpygSS1q5/03CMUxEiLTZMLmG\nVX/+UgEWzdEe1Dr/7YPYcOSPiBpTRyPiq6KQC/KtPPWHouGyaPO1SAAZhlk97dQs\n9mosf4Re69Re8U6MxxGw9Q==\n-----END PRIVATE KEY-----\n", + "client_email": "image-search-service@ncp-1-483610.iam.gserviceaccount.com", + "client_id": "105492884344006453429", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/image-search-service%40ncp-1-483610.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +}