diff --git a/VERSION b/VERSION index ee6cdce..eb49d7c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.1 +0.7 diff --git a/src/main/java/it/garambo/retrosearch/configuration/BeanConfig.java b/src/main/java/it/garambo/retrosearch/configuration/BeanConfig.java index aa7f9ea..04fb85a 100644 --- a/src/main/java/it/garambo/retrosearch/configuration/BeanConfig.java +++ b/src/main/java/it/garambo/retrosearch/configuration/BeanConfig.java @@ -1,5 +1,7 @@ package it.garambo.retrosearch.configuration; +import java.util.List; +import java.util.Locale; import org.apache.commons.validator.routines.UrlValidator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -7,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Scope; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.util.StringUtils; import org.thymeleaf.spring6.SpringTemplateEngine; import org.thymeleaf.spring6.view.ThymeleafViewResolver; @@ -34,6 +37,16 @@ public ApplicationSettings applicationSettings( return new ApplicationSettings(HTMLVersion.getByVersionName(htmlVersionValue), encodingValue); } + @Bean + public NewsSettings newsSettings( + @Value("${retrosearch.news.enable}") boolean enabled, + @Value("${retrosearch.news.api.locales}") List localeList, + @Value("${retrosearch.news.api.rate.limiter}") long rateLimiter) { + List locales = localeList.stream().map(StringUtils::parseLocaleString).toList(); + + return new NewsSettings(enabled, rateLimiter, locales); + } + @Bean public ThymeleafViewResolver thymeleafViewResolver( @Autowired SpringTemplateEngine templateEngine, diff --git a/src/main/java/it/garambo/retrosearch/configuration/NewsSettings.java b/src/main/java/it/garambo/retrosearch/configuration/NewsSettings.java new file mode 100644 index 0000000..5a55678 --- /dev/null +++ b/src/main/java/it/garambo/retrosearch/configuration/NewsSettings.java @@ -0,0 +1,19 @@ +package it.garambo.retrosearch.configuration; + +import java.util.List; +import java.util.Locale; +import lombok.Getter; + +@Getter +public class NewsSettings { + + private final boolean enabled; + private final long rateLimiter; + private final List locales; + + public NewsSettings(boolean enabled, long rateLimiter, List locales) { + this.enabled = enabled; + this.rateLimiter = rateLimiter; + this.locales = locales; + } +} diff --git a/src/main/java/it/garambo/retrosearch/controller/NewsController.java b/src/main/java/it/garambo/retrosearch/controller/NewsController.java index c63047e..adc44ea 100644 --- a/src/main/java/it/garambo/retrosearch/controller/NewsController.java +++ b/src/main/java/it/garambo/retrosearch/controller/NewsController.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; @Controller @ConditionalOnBean(NewsRepository.class) @@ -14,9 +15,14 @@ public class NewsController { @Autowired private NewsRepository newsRepository; @GetMapping("/news") - public String news(Model model) { + public String news(@RequestParam String country, Model model) { + if (country.isEmpty() || !newsRepository.isCountrySupported(country)) { + return "redirect:/"; + } + + model.addAttribute("country", country); model.addAttribute("updatedAt", newsRepository.getUpdatedAt()); - model.addAttribute("articles", newsRepository.getAllArticles()); + model.addAttribute("articles", newsRepository.getArticlesByCountry(country)); return "news"; } } diff --git a/src/main/java/it/garambo/retrosearch/news/client/GNewsApiClient.java b/src/main/java/it/garambo/retrosearch/news/client/GNewsApiClient.java index 6bc59f2..064409c 100644 --- a/src/main/java/it/garambo/retrosearch/news/client/GNewsApiClient.java +++ b/src/main/java/it/garambo/retrosearch/news/client/GNewsApiClient.java @@ -23,14 +23,15 @@ public class GNewsApiClient { @Autowired HttpService httpService; - public GNewsApiResponse fetchNews() throws URISyntaxException, IOException { + public GNewsApiResponse fetchNews(String language, String country) + throws URISyntaxException, IOException { URI apiUri = new URI(API_URL); Map params = Map.of( "category", "general", "max", "10", - "lang", "en", - "country", "us", + "lang", language, + "country", country, "apikey", apiKey); String response = httpService.get(apiUri, params); diff --git a/src/main/java/it/garambo/retrosearch/news/repository/InMemoryNewsRepository.java b/src/main/java/it/garambo/retrosearch/news/repository/InMemoryNewsRepository.java index 08f6bc0..f914d3f 100644 --- a/src/main/java/it/garambo/retrosearch/news/repository/InMemoryNewsRepository.java +++ b/src/main/java/it/garambo/retrosearch/news/repository/InMemoryNewsRepository.java @@ -1,8 +1,11 @@ package it.garambo.retrosearch.news.repository; +import static java.util.Objects.isNull; + import it.garambo.retrosearch.news.model.Article; import java.util.Date; import java.util.List; +import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -12,24 +15,24 @@ @ConditionalOnProperty(value = "retrosearch.news.enable", havingValue = "true") public class InMemoryNewsRepository implements NewsRepository { - private List
articles; + private Map> articles; private Date updatedAt; @Override - public List
getAllArticles() { - return articles; + public List
getArticlesByCountry(String country) { + return articles.get(country); } @Override - public Article getArticle(int index) { - return articles.get(index); + public boolean isCountrySupported(String country) { + return !isNull(articles) && articles.containsKey(country); } @Override - public void updateAll(List
newArticles) { + public void updateAll(Map> newArticles) { articles = newArticles; updatedAt = new Date(); - log.info("Article list updated"); + log.info("Article list updated, news loaded for {}", articles.keySet()); } @Override diff --git a/src/main/java/it/garambo/retrosearch/news/repository/NewsRepository.java b/src/main/java/it/garambo/retrosearch/news/repository/NewsRepository.java index 37751a0..79c91ec 100644 --- a/src/main/java/it/garambo/retrosearch/news/repository/NewsRepository.java +++ b/src/main/java/it/garambo/retrosearch/news/repository/NewsRepository.java @@ -3,14 +3,15 @@ import it.garambo.retrosearch.news.model.Article; import java.util.Date; import java.util.List; +import java.util.Map; public interface NewsRepository { - List
getAllArticles(); + List
getArticlesByCountry(String country); - Article getArticle(int index); - - void updateAll(List
newArticles); + void updateAll(Map> newArticles); Date getUpdatedAt(); + + boolean isCountrySupported(String country); } diff --git a/src/main/java/it/garambo/retrosearch/news/scheduled/NewsScheduledTask.java b/src/main/java/it/garambo/retrosearch/news/scheduled/NewsScheduledTask.java index 0b8f5c8..6f395ec 100644 --- a/src/main/java/it/garambo/retrosearch/news/scheduled/NewsScheduledTask.java +++ b/src/main/java/it/garambo/retrosearch/news/scheduled/NewsScheduledTask.java @@ -1,9 +1,16 @@ package it.garambo.retrosearch.news.scheduled; +import it.garambo.retrosearch.configuration.NewsSettings; import it.garambo.retrosearch.news.client.GNewsApiClient; import it.garambo.retrosearch.news.model.Article; import it.garambo.retrosearch.news.repository.NewsRepository; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Executors; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -14,19 +21,41 @@ @Component @ConditionalOnProperty(value = "retrosearch.news.enable", havingValue = "true") public class NewsScheduledTask { + @Autowired private NewsSettings settings; @Autowired private GNewsApiClient apiClient; @Autowired private NewsRepository repository; @Scheduled(fixedRate = 60 * 60 * 1000) - private void updateNews() { - try { - log.info("Updating article lists..."); - List
articles = apiClient.fetchNews().articles(); - repository.updateAll(articles); - } catch (Exception e) { - log.error("Article list update failed:", e); - } + private void updateNews() throws Exception { + List locales = settings.getLocales(); + log.info("Updating article lists... Requested Locales: {}", locales); + + Map> newArticles = new HashMap<>(); + + Executors.newScheduledThreadPool(1) + .execute( + () -> { + for (Locale locale : locales) { + try { + List
articles = + apiClient.fetchNews(locale.getLanguage(), locale.getCountry()).articles(); + newArticles.put(locale.getDisplayCountry(), articles); + log.info("Fetched news for {}", locale); + Thread.sleep(settings.getRateLimiter()); + } catch (IOException | URISyntaxException | InterruptedException e) { + log.error("Could not fetch news for {}", locale, e); + } + } + + if (newArticles.keySet().size() < locales.size()) { + log.error( + "Could not retrieve news for all countries/locales. The current news map won't be updated."); + } else { + log.info("News fetching complete for requested locales: {}", locales); + repository.updateAll(newArticles); + } + }); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a2706d6..167c4a5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,6 +5,8 @@ retrosearch.encoding=UTF-8 retrosearch.html.version=3.2 retrosearch.news.enable=${NEWS_ACTIVE:false} retrosearch.news.api.key=${NEWS_API_KEY:} +retrosearch.news.api.rate.limiter=${NEWS_API_RATE_LIMITER:3000} +retrosearch.news.api.locales=${NEWS_API_LOCALES:en_US,en_UK,it_IT,de_DE} retrosearch.sports.football.enable=${FOOTBALL_API_ACTIVE:false} retrosearch.sports.football.api.key=${FOOTBALL_API_KEY:} diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index be8bfd6..8dfb0f8 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -1,10 +1,25 @@ A RetroSearch Logo - -

Search and Browse - News - Football scores

+

+ Home + + - News ( + + + + + ) + + + + - Football scores + +

+
Search Query: -
-
\ No newline at end of file + \ No newline at end of file diff --git a/src/main/resources/templates/news.html b/src/main/resources/templates/news.html index 7c4e60f..efa5087 100644 --- a/src/main/resources/templates/news.html +++ b/src/main/resources/templates/news.html @@ -6,7 +6,7 @@ -

Latest news

+

Latest news |

Updated:

  • diff --git a/src/test/java/it/garambo/retrosearch/PrimaryTestConfiguration.java b/src/test/java/it/garambo/retrosearch/PrimaryTestConfiguration.java index a98e3f2..13d0cfa 100644 --- a/src/test/java/it/garambo/retrosearch/PrimaryTestConfiguration.java +++ b/src/test/java/it/garambo/retrosearch/PrimaryTestConfiguration.java @@ -1,5 +1,7 @@ package it.garambo.retrosearch; +import it.garambo.retrosearch.news.repository.InMemoryNewsRepository; +import it.garambo.retrosearch.news.repository.NewsRepository; import it.garambo.retrosearch.sports.football.repository.FootballRepository; import it.garambo.retrosearch.sports.football.repository.InMemoryFootballRepository; import org.springframework.boot.test.context.TestConfiguration; @@ -11,7 +13,13 @@ public class PrimaryTestConfiguration { @Bean @Primary - public FootballRepository repository() { + public FootballRepository footballRepository() { return new InMemoryFootballRepository(); } + + @Bean + @Primary + public NewsRepository newsRepository() { + return new InMemoryNewsRepository(); + } } diff --git a/src/test/java/it/garambo/retrosearch/news/client/GNewsApiClientTest.java b/src/test/java/it/garambo/retrosearch/news/client/GNewsApiClientTest.java new file mode 100644 index 0000000..911af5e --- /dev/null +++ b/src/test/java/it/garambo/retrosearch/news/client/GNewsApiClientTest.java @@ -0,0 +1,62 @@ +package it.garambo.retrosearch.news.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import it.garambo.retrosearch.PrimaryTestConfiguration; +import it.garambo.retrosearch.http.HttpService; +import it.garambo.retrosearch.news.model.GNewsApiResponse; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.Resource; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +@SpringBootTest(classes = PrimaryTestConfiguration.class) +class GNewsApiClientTest { + + @Mock private HttpService httpService; + + @InjectMocks GNewsApiClient client; + + @BeforeEach + void setup() { + ReflectionTestUtils.setField(client, "apiKey", "testKey"); + } + + @Test + void testFetchNews(@Value("classpath:news/response.json") Resource responseJson) + throws IOException, URISyntaxException { + URI uri = new URI("https://gnews.io/api/v4/top-headlines"); + + Map params = + Map.of( + "category", "general", + "max", "10", + "lang", "it", + "country", "it", + "apikey", "testKey"); + + when(httpService.get(eq(uri), eq(params))) + .thenReturn(responseJson.getContentAsString(StandardCharsets.UTF_8)); + + GNewsApiResponse response = client.fetchNews("it", "it"); + assertNotNull(response); + assertEquals(54904, response.totalArticles()); + assertNotNull(response.articles()); + assertEquals(1, response.articles().size()); + } +} diff --git a/src/test/java/it/garambo/retrosearch/news/repository/InMemoryNewsRepositoryTest.java b/src/test/java/it/garambo/retrosearch/news/repository/InMemoryNewsRepositoryTest.java new file mode 100644 index 0000000..6f2847a --- /dev/null +++ b/src/test/java/it/garambo/retrosearch/news/repository/InMemoryNewsRepositoryTest.java @@ -0,0 +1,49 @@ +package it.garambo.retrosearch.news.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import it.garambo.retrosearch.PrimaryTestConfiguration; +import it.garambo.retrosearch.news.model.Article; +import it.garambo.retrosearch.news.model.GNewsApiResponse; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.Resource; + +@SpringBootTest(classes = PrimaryTestConfiguration.class) +@ExtendWith(MockitoExtension.class) +class InMemoryNewsRepositoryTest { + + @Autowired NewsRepository newsRepository; + + @Test + void testUpdate(@Value("classpath:news/response.json") Resource responseJson) + throws IOException, URISyntaxException { + + String content = responseJson.getContentAsString(StandardCharsets.UTF_8); + GNewsApiResponse response = new ObjectMapper().readValue(content, GNewsApiResponse.class); + assertNotNull(response); + + Map> articles = Map.of("Example", response.articles()); + + newsRepository.updateAll(articles); + + assertNotNull(newsRepository.getUpdatedAt()); + assertTrue(newsRepository.isCountrySupported("Example")); + assertNotNull(newsRepository.getArticlesByCountry("Example")); + assertEquals(1, newsRepository.getArticlesByCountry("Example").size()); + assertFalse(newsRepository.isCountrySupported("Wrong")); + } +} diff --git a/src/test/java/it/garambo/retrosearch/sports/football/repository/InMemoryFootballRepositoryTest.java b/src/test/java/it/garambo/retrosearch/sports/football/repository/InMemoryFootballRepositoryTest.java index 2fad000..252c870 100644 --- a/src/test/java/it/garambo/retrosearch/sports/football/repository/InMemoryFootballRepositoryTest.java +++ b/src/test/java/it/garambo/retrosearch/sports/football/repository/InMemoryFootballRepositoryTest.java @@ -22,7 +22,7 @@ @ExtendWith(MockitoExtension.class) class InMemoryFootballRepositoryTest { - @Autowired FootballRepository repository; + @Autowired FootballRepository footballRepository; @Test void testUpdate(@Value("classpath:sports/football/response.json") Resource responseJson) @@ -31,14 +31,14 @@ void testUpdate(@Value("classpath:sports/football/response.json") Resource respo FootballDataResponse response = new ObjectMapper().readValue(content, FootballDataResponse.class); assertNotNull(response); - repository.updateAll(response.matches()); - assertNotNull(repository.getUpdatedAt()); + footballRepository.updateAll(response.matches()); + assertNotNull(footballRepository.getUpdatedAt()); - Map> matches = repository.getAllMatches(); + Map> matches = footballRepository.getAllMatches(); assertEquals(7, matches.keySet().size()); assertTrue(matches.containsKey("Italy")); - Set italianMatches = repository.getAllMatchesByArea("Italy"); + Set italianMatches = footballRepository.getAllMatchesByArea("Italy"); assertEquals(5, italianMatches.size()); } } diff --git a/src/test/resources/news/response.json b/src/test/resources/news/response.json new file mode 100644 index 0000000..90d0649 --- /dev/null +++ b/src/test/resources/news/response.json @@ -0,0 +1,17 @@ +{ + "totalArticles": 54904, + "articles": [ + { + "title": "Google's Pixel 7 and 7 Pro’s design gets revealed even more with fresh crisp renders", + "description": "Now we have a complete image of what the next Google flagship phones will look like. All that's left now is to welcome them during their October announcement!", + "content": "Google’s highly anticipated upcoming Pixel 7 series is just around the corner, scheduled to be announced on October 6, 2022, at 10 am EDT during the Made by Google event. Well, not that there is any lack of images showing the two new Google phones, b... [1419 chars]", + "url": "https://www.phonearena.com/news/google-pixel-7-and-pro-design-revealed-even-more-fresh-renders_id142800", + "image": "https://m-cdn.phonearena.com/images/article/142800-wide-two_1200/Googles-Pixel-7-and-7-Pros-design-gets-revealed-even-more-with-fresh-crisp-renders.jpg", + "publishedAt": "2022-09-28T08:14:24Z", + "source": { + "name": "PhoneArena", + "url": "https://www.phonearena.com" + } + } + ] +} \ No newline at end of file