Newer
Older
book-metadata-service / src / main / java / ru / mcs / metadatabook / controller / PublicSearchController.java
package ru.mcs.metadatabook.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ru.mcs.metadatabook.entity.ApiKeyScope;
import ru.mcs.metadatabook.entity.MtData;
import ru.mcs.metadatabook.entity.MtField;
import ru.mcs.metadatabook.entity.MtObject;
import ru.mcs.metadatabook.service.ApiKeyService;
import ru.mcs.metadatabook.service.BookQueryService;

import java.util.List;
import java.util.Map;

/**
 * Анонимный публичный поиск для сайта — единственная точка входа для
 * посетителей без собственного API-ключа.
 *
 * Внутри использует ОДИН общий системный ключ с scope=PUBLIC_READ
 * (book.api.public-api-key), который никогда не передаётся в браузер —
 * фронтенд ходит только на этот эндпоинт, а не напрямую в /api/v1/books.
 *
 * Защита от злоупотреблений — Bucket4j по getRemoteAddr() на url /api/v1/public/*
 * (см. application.yml, bucket4j.filters). Здесь никакой отдельной логики лимитов нет —
 * фильтр Bucket4j срабатывает раньше, чем запрос долетит до этого метода.
 */
@RestController
@RequestMapping("/api/v1/public")
@RequiredArgsConstructor
@Tag(name = "Public Search", description = "Анонимный поиск книг для сайта (rate-limited)")
public class PublicSearchController {

    private final ApiKeyService apiKeyService;
    private final BookQueryService bookQueryService;

    @Value("${book.api.public-api-key}")
    private String publicApiKey;

    @Operation(summary = "Поиск книги по ISBN или названию (для анонимных посетителей сайта)")
    @GetMapping("/search")
    public ResponseEntity<List<Map<String, Object>>> search(
            @RequestParam(required = false) String isbn,
            @RequestParam(required = false) String title,
            @RequestParam(defaultValue = "20") int size) {

        if ((isbn == null || isbn.isBlank()) && (title == null || title.isBlank())) {
            throw new IllegalArgumentException("Provide either isbn or title");
        }

        Integer orgId = apiKeyService.resolveOrgIdOrThrow(publicApiKey, ApiKeyScope.PUBLIC_READ);
        MtObject book = bookQueryService.getBookObject(orgId);
        // Публичному поиску не отдаём служебные/непроверенные поля -
        // SourceStatus/провенанс не нужны стороннему посетителю сайта.
        List<MtField> publicFields = bookQueryService.resolveRequestedFields(
                orgId, book.getObjId(), "Title,Subtitle,AuthorName,Publisher,PublishedYear,ISBN,ISBN13,CoverUrl,Genre,Description");

        List<MtData> records;
        if (isbn != null && !isbn.isBlank()) {
            records = bookQueryService.findByIndexedField(orgId, book.getObjId(), "ISBN", isbn);
        } else {
            records = bookQueryService.findByIndexedFieldContaining(orgId, book.getObjId(), "Title", title);
        }

        return ResponseEntity.ok(records.stream()
                .limit(Math.min(size, 50))
                .map(r -> bookQueryService.toResponseMap(r, publicFields))
                .toList());
    }
}