diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/CoreShibUiConfiguration.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/CoreShibUiConfiguration.java index edcfc4ccb..ca7be77ea 100644 --- a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/CoreShibUiConfiguration.java +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/CoreShibUiConfiguration.java @@ -21,8 +21,12 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; import org.springframework.web.util.UrlPathHelper; import javax.servlet.http.HttpServletRequest; @@ -78,6 +82,11 @@ public AttributeUtility attributeUtility() { @Autowired Directory directory; + @Autowired + LocaleResolver localeResolver; + + @Autowired + ResourceBundleMessageSource messageSource; @Bean public EntityDescriptorFilesScheduledTasks entityDescriptorFilesScheduledTasks(EntityDescriptorRepository entityDescriptorRepository) { @@ -103,6 +112,13 @@ public EntityIdsSearchService entityIdsSearchService() { }; } + @Bean + public LocaleChangeInterceptor localeChangeInterceptor() { + LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor(); + localeChangeInterceptor.setParamName("lang"); + return localeChangeInterceptor; + } + /** * A WebMvcConfigurer that won't mangle the path for the entities endpoint. * @@ -139,6 +155,11 @@ public String getOriginatingServletPath(HttpServletRequest request) { helper.setUrlDecode(false); configurer.setUrlPathHelper(helper); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(localeChangeInterceptor()); + } }; } } diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/InternationalizationConfiguration.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/InternationalizationConfiguration.java new file mode 100644 index 000000000..dc8599865 --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/configuration/InternationalizationConfiguration.java @@ -0,0 +1,25 @@ +package edu.internet2.tier.shibboleth.admin.ui.configuration; + +import edu.internet2.tier.shibboleth.admin.ui.i18n.MappedResourceBundleMessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.i18n.SessionLocaleResolver; + +@Configuration +public class InternationalizationConfiguration { + @Bean + public LocaleResolver localeResolver() { + // TODO if we want to control the order, we can implement our own locale resolver instead of using the SessionLocaleResolver. + SessionLocaleResolver sessionLocaleResolver = new SessionLocaleResolver(); + return sessionLocaleResolver; + } + + @Bean + public MappedResourceBundleMessageSource messageSource() { + MappedResourceBundleMessageSource source = new MappedResourceBundleMessageSource(); + source.setBasenames("i18n/messages"); + source.setUseCodeAsDefaultMessage(true); + return source; + } +} diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/InternationalizationMessagesController.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/InternationalizationMessagesController.java new file mode 100644 index 000000000..566dbe4a3 --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/controller/InternationalizationMessagesController.java @@ -0,0 +1,25 @@ +package edu.internet2.tier.shibboleth.admin.ui.controller; + +import edu.internet2.tier.shibboleth.admin.ui.i18n.MappedResourceBundleMessageSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Locale; + +/** + * @author Bill Smith (wsmith@unicon.net) + */ +@Controller +@RequestMapping(value = "/api/messages") +public class InternationalizationMessagesController { + @Autowired + MappedResourceBundleMessageSource messageSource; + + @GetMapping + public ResponseEntity getAll(Locale locale) { + return ResponseEntity.ok(messageSource.getMessagesMap(locale)); + } +} diff --git a/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/i18n/MappedResourceBundleMessageSource.java b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/i18n/MappedResourceBundleMessageSource.java new file mode 100644 index 000000000..672cca880 --- /dev/null +++ b/backend/src/main/java/edu/internet2/tier/shibboleth/admin/ui/i18n/MappedResourceBundleMessageSource.java @@ -0,0 +1,28 @@ +package edu.internet2.tier.shibboleth.admin.ui.i18n; + +import org.springframework.context.support.ResourceBundleMessageSource; + +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.ResourceBundle; + +/** + * @author Bill Smith (wsmith@unicon.net) + */ +public class MappedResourceBundleMessageSource extends ResourceBundleMessageSource { + public Map getMessagesMap(Locale locale) { + ResourceBundle resourceBundle = this.doGetBundle("i18n/messages", locale); + Map messagesMap = new HashMap<>(); + Enumeration bundleKeys = resourceBundle.getKeys(); + + while (bundleKeys.hasMoreElements()) { + String key = (String)bundleKeys.nextElement(); + String value = resourceBundle.getString(key); + messagesMap.put(key, value); + } + + return messagesMap; + } +} diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties new file mode 100644 index 000000000..d9a5d0df4 --- /dev/null +++ b/backend/src/main/resources/i18n/messages.properties @@ -0,0 +1,10 @@ +# Fill this file with key/value pairs, as follows: +# +# some.test.message=This is a test message. +# +# Then, create a copy using the name of the language code: +# +# messages_.properties +# +# Do this for each language we want to support. +# Ideally, all messages should exist for each language. diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties new file mode 100644 index 000000000..5c101832f --- /dev/null +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -0,0 +1 @@ +a.sample.message=This is a sample message. \ No newline at end of file diff --git a/backend/src/main/resources/i18n/messages_fr.properties b/backend/src/main/resources/i18n/messages_fr.properties new file mode 100644 index 000000000..750c5fe51 --- /dev/null +++ b/backend/src/main/resources/i18n/messages_fr.properties @@ -0,0 +1 @@ +a.sample.message=Le francais est tres difficile. \ No newline at end of file diff --git a/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/InternationalizationMessagesControllerTests.groovy b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/InternationalizationMessagesControllerTests.groovy new file mode 100644 index 000000000..48a536d19 --- /dev/null +++ b/backend/src/test/groovy/edu/internet2/tier/shibboleth/admin/ui/controller/InternationalizationMessagesControllerTests.groovy @@ -0,0 +1,113 @@ +package edu.internet2.tier.shibboleth.admin.ui.controller + +import edu.internet2.tier.shibboleth.admin.ui.configuration.CoreShibUiConfiguration +import edu.internet2.tier.shibboleth.admin.ui.configuration.InternationalizationConfiguration +import edu.internet2.tier.shibboleth.admin.ui.configuration.SearchConfiguration +import edu.internet2.tier.shibboleth.admin.ui.configuration.TestConfiguration +import edu.internet2.tier.shibboleth.admin.ui.i18n.MappedResourceBundleMessageSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.servlet.LocaleResolver +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor +import spock.lang.Specification + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content + +/** + * @author Bill Smith (wsmith@unicon.net) + */ +@DataJpaTest +@ContextConfiguration(classes=[CoreShibUiConfiguration, SearchConfiguration, TestConfiguration, InternationalizationConfiguration]) +@EnableJpaRepositories(basePackages = ["edu.internet2.tier.shibboleth.admin.ui"]) +@EntityScan("edu.internet2.tier.shibboleth.admin.ui") +class InternationalizationMessagesControllerTests extends Specification { + @Autowired + MappedResourceBundleMessageSource messageSource + + @Autowired + LocaleChangeInterceptor localeChangeInterceptor + + @Autowired + LocaleResolver localResolver + + def controller + def mockMvc + + def setup() { + controller = new InternationalizationMessagesController( + messageSource: messageSource + ) + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setLocaleResolver(localResolver) + .addInterceptors(localeChangeInterceptor) + .build() + } + + def messagesUrl = "/api/messages" + + def expectedEnglishResult = + '{' + + ' "some.test.message": "This is the English test message."' + + '}' + + def expectedFrenchResult = + '{' + + ' "some.test.message": "Je ne sais pas Francais."' + + '}' + + def "GET messages with no header or \"lang\" param defaults to returning english messages"() { + when: + def result = mockMvc.perform( + get(messagesUrl)) + + then: + result.andExpect(content().json(expectedEnglishResult)) + } + + def "GET messages with Accept-Language returns messages in that language"() { + when: + def result = mockMvc.perform( + get(messagesUrl) + .header("Accept-Language", "fr")) + + then: + result.andExpect(content().json(expectedFrenchResult)) + } + + def "GET messages with \"lang\" request param returns messages in that language"() { + when: + def result = mockMvc.perform( + get(messagesUrl) + .param("lang", "fr")) + + then: + result.andExpect(content().json(expectedFrenchResult)) + } + + def "GET messages with both Accept-Language header and \"lang\" request param returns messages in the language specified by the \"lang\" parameter"() { + when: + def result = mockMvc.perform( + get(messagesUrl) + .header("Accept-Language", "en") + .param("lang", "fr")) + + then: + result.andExpect(content().json(expectedFrenchResult)) + } + + def "GET messages with an unsupported Accept-Language returns the default language"() { + when: + def result = mockMvc.perform( + get(messagesUrl) + .header("Accept-Language", "es")) + + then: + result.andExpect(content().json(expectedEnglishResult)) + } +} diff --git a/backend/src/test/resources/i18n/messages.properties b/backend/src/test/resources/i18n/messages.properties new file mode 100644 index 000000000..3e763c8d5 --- /dev/null +++ b/backend/src/test/resources/i18n/messages.properties @@ -0,0 +1 @@ +some.test.message=This is the default message. \ No newline at end of file diff --git a/backend/src/test/resources/i18n/messages_en.properties b/backend/src/test/resources/i18n/messages_en.properties new file mode 100644 index 000000000..3a05d1b5c --- /dev/null +++ b/backend/src/test/resources/i18n/messages_en.properties @@ -0,0 +1 @@ +some.test.message=This is the English test message. \ No newline at end of file diff --git a/backend/src/test/resources/i18n/messages_fr.properties b/backend/src/test/resources/i18n/messages_fr.properties new file mode 100644 index 000000000..23ded1634 --- /dev/null +++ b/backend/src/test/resources/i18n/messages_fr.properties @@ -0,0 +1 @@ +some.test.message=Je ne sais pas Francais. \ No newline at end of file