diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/ActivityDefinition.java b/xapi-model/src/main/java/dev/learning/xapi/model/ActivityDefinition.java index c20ef990..a2f38ba5 100644 --- a/xapi-model/src/main/java/dev/learning/xapi/model/ActivityDefinition.java +++ b/xapi-model/src/main/java/dev/learning/xapi/model/ActivityDefinition.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import dev.learning.xapi.model.validation.constraints.HasScheme; +import dev.learning.xapi.model.validation.constraints.ValidLocale; import java.net.URI; import java.util.ArrayList; import java.util.List; @@ -33,11 +34,13 @@ public class ActivityDefinition { /** * The human readable/visual name of the Activity. */ + @ValidLocale private LanguageMap name; /** * A description of the Activity. */ + @ValidLocale private LanguageMap description; /** @@ -92,7 +95,8 @@ public class ActivityDefinition { */ private Map<@HasScheme URI, Object> extensions; - // **Warning** do not add fields that are not required by the xAPI specification. + // **Warning** do not add fields that are not required by the xAPI + // specification. /** * Builder for ActivityDefinition. diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java b/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java index f40d697c..6ed363bc 100644 --- a/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java +++ b/xapi-model/src/main/java/dev/learning/xapi/model/Attachment.java @@ -7,7 +7,9 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; +import dev.learning.xapi.model.validation.constraints.ValidLocale; import jakarta.validation.constraints.NotNull; +import jakarta.validation.valueextraction.Unwrapping; import java.net.URI; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -40,12 +42,14 @@ public class Attachment { /** * Display name of this Attachment. */ - @NotNull + @NotNull(payload = Unwrapping.Skip.class) + @ValidLocale private LanguageMap display; /** * A description of the Attachment. */ + @ValidLocale private LanguageMap description; /** diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/Context.java b/xapi-model/src/main/java/dev/learning/xapi/model/Context.java index 2be3c0aa..7b4e9cce 100644 --- a/xapi-model/src/main/java/dev/learning/xapi/model/Context.java +++ b/xapi-model/src/main/java/dev/learning/xapi/model/Context.java @@ -11,6 +11,7 @@ import dev.learning.xapi.model.validation.constraints.HasScheme; import dev.learning.xapi.model.validation.constraints.NotUndetermined; import dev.learning.xapi.model.validation.constraints.ValidActor; +import dev.learning.xapi.model.validation.constraints.ValidLocale; import dev.learning.xapi.model.validation.constraints.Variant; import jakarta.validation.Valid; import java.net.URI; @@ -76,6 +77,7 @@ public class Context { */ @NotUndetermined @JsonSerialize(using = LocaleSerializer.class) + @ValidLocale private Locale language; /** diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/InteractionComponent.java b/xapi-model/src/main/java/dev/learning/xapi/model/InteractionComponent.java index b42bc529..26d7b30e 100644 --- a/xapi-model/src/main/java/dev/learning/xapi/model/InteractionComponent.java +++ b/xapi-model/src/main/java/dev/learning/xapi/model/InteractionComponent.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; +import dev.learning.xapi.model.validation.constraints.ValidLocale; import jakarta.validation.constraints.NotNull; import java.util.Locale; import lombok.Builder; @@ -36,6 +37,7 @@ public class InteractionComponent { /** * A description of the interaction component. */ + @ValidLocale private LanguageMap description; // **Warning** do not add fields that are not required by the xAPI specification. diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java b/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java index 06fc81d9..4d6d5e78 100644 --- a/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java +++ b/xapi-model/src/main/java/dev/learning/xapi/model/Statement.java @@ -100,6 +100,7 @@ public class Statement implements CoreStatement { /** * Agent or Group who is asserting this Statement is true. */ + @Valid @ValidActor @ValidAuthority private Actor authority; @@ -113,6 +114,7 @@ public class Statement implements CoreStatement { /** * Headers for Attachments to the Statement. */ + @Valid @JsonFormat(without = {JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY}) private List attachments; @@ -349,7 +351,7 @@ public Builder addAttachment(Attachment attachment) { */ public Builder addAttachment(Consumer attachment) { - final Attachment.Builder builder = Attachment.builder(); + final var builder = Attachment.builder(); attachment.accept(builder); diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/Verb.java b/xapi-model/src/main/java/dev/learning/xapi/model/Verb.java index c90fd87c..b1a209e9 100644 --- a/xapi-model/src/main/java/dev/learning/xapi/model/Verb.java +++ b/xapi-model/src/main/java/dev/learning/xapi/model/Verb.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import dev.learning.xapi.model.validation.constraints.HasScheme; +import dev.learning.xapi.model.validation.constraints.ValidLocale; import jakarta.validation.constraints.NotNull; import java.net.URI; import java.util.Locale; @@ -351,6 +352,7 @@ public class Verb { * impact on the meaning of the Statement, but serves to give a human-readable display of the * meaning already determined by the chosen Verb. */ + @ValidLocale private LanguageMap display; // **Warning** do not add fields that are not required by the xAPI specification. diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/validation/constraints/ValidLocale.java b/xapi-model/src/main/java/dev/learning/xapi/model/validation/constraints/ValidLocale.java new file mode 100644 index 00000000..3154280c --- /dev/null +++ b/xapi-model/src/main/java/dev/learning/xapi/model/validation/constraints/ValidLocale.java @@ -0,0 +1,47 @@ +/* + * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.model.validation.constraints; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.CONSTRUCTOR; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE_USE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * The annotated Locale must have a ISO3 Language and Country. + * + * @author István Rátkai (Selindek) + */ +@Documented +@Constraint(validatedBy = {}) +@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) +@Retention(RUNTIME) +public @interface ValidLocale { + + /** + * Error Message. + */ + String message() default "all keys must have a ISO3 Language and Country"; + + /** + * Groups. + */ + Class[] groups() default {}; + + /** + * Payload. + */ + Class[] payload() default {}; + +} diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/LanguageMapValueExtractor.java b/xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/LanguageMapValueExtractor.java new file mode 100644 index 00000000..a27ddc5f --- /dev/null +++ b/xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/LanguageMapValueExtractor.java @@ -0,0 +1,27 @@ +/* + * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.model.validation.internal.validators; + +import dev.learning.xapi.model.LanguageMap; +import jakarta.validation.valueextraction.ExtractedValue; +import jakarta.validation.valueextraction.UnwrapByDefault; +import jakarta.validation.valueextraction.ValueExtractor; +import java.util.Locale; + +/** + * ValueExtractor for {@link LanguageMap}. + * + * @author István Rátkai (Selindek) + */ +@UnwrapByDefault +public class LanguageMapValueExtractor + implements ValueExtractor<@ExtractedValue(type = Locale.class) LanguageMap> { + + @Override + public void extractValues(LanguageMap originalValue, ValueReceiver receiver) { + originalValue.keySet().forEach(k -> receiver.iterableValue(k.toLanguageTag(), k)); + } + +} diff --git a/xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/LocaleValidator.java b/xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/LocaleValidator.java new file mode 100644 index 00000000..85b5dcd0 --- /dev/null +++ b/xapi-model/src/main/java/dev/learning/xapi/model/validation/internal/validators/LocaleValidator.java @@ -0,0 +1,50 @@ +/* + * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.model.validation.internal.validators; + +import dev.learning.xapi.model.validation.constraints.ValidLocale; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Locale; +import java.util.MissingResourceException; + +/** + * The Locale being validated must have a ISO3 Language and Country. + * + * @author István Rátkai (Selindek) + * @author Thomas Turrell-Croft + */ +public class LocaleValidator implements ConstraintValidator { + + @Override + public boolean isValid(Locale locale, ConstraintValidatorContext context) { + + if (locale == null) { + return true; + } + + try { + locale.getISO3Language(); + locale.getISO3Country(); + + return true; + } catch (final MissingResourceException e1) { + + // Handle locale instantiated with Locale#Locale(String) + final var blar = Locale.forLanguageTag(locale.toString()); + + try { + blar.getISO3Language(); + blar.getISO3Country(); + + return true; + } catch (final MissingResourceException e2) { + + return false; + } + } + } + +} diff --git a/xapi-model/src/main/resources/META-INF/services/jakarta.validation.ConstraintValidator b/xapi-model/src/main/resources/META-INF/services/jakarta.validation.ConstraintValidator index fd019e08..97a28183 100644 --- a/xapi-model/src/main/resources/META-INF/services/jakarta.validation.ConstraintValidator +++ b/xapi-model/src/main/resources/META-INF/services/jakarta.validation.ConstraintValidator @@ -10,4 +10,5 @@ dev.learning.xapi.model.validation.internal.validators.StatementRevisionValidato dev.learning.xapi.model.validation.internal.validators.StatementPlatformValidator dev.learning.xapi.model.validation.internal.validators.StatementVerbValidator dev.learning.xapi.model.validation.internal.validators.StatementsValidator +dev.learning.xapi.model.validation.internal.validators.LocaleValidator dev.learning.xapi.model.validation.internal.validators.ScoreValidator diff --git a/xapi-model/src/main/resources/META-INF/services/jakarta.validation.valueextraction.ValueExtractor b/xapi-model/src/main/resources/META-INF/services/jakarta.validation.valueextraction.ValueExtractor new file mode 100644 index 00000000..1ad0af7d --- /dev/null +++ b/xapi-model/src/main/resources/META-INF/services/jakarta.validation.valueextraction.ValueExtractor @@ -0,0 +1 @@ +dev.learning.xapi.model.validation.internal.validators.LanguageMapValueExtractor diff --git a/xapi-model/src/test/java/dev/learning/xapi/model/VerbTests.java b/xapi-model/src/test/java/dev/learning/xapi/model/VerbTests.java index 2d0de8d7..a811f4b3 100644 --- a/xapi-model/src/test/java/dev/learning/xapi/model/VerbTests.java +++ b/xapi-model/src/test/java/dev/learning/xapi/model/VerbTests.java @@ -319,7 +319,7 @@ void whenValidatingVerbWithAllRequiredPropertiesThenConstraintViolationsSizeIsZe final var verb = Verb.builder().id("http://adlnet.gov/expapi/verbs/answered") .addDisplay(Locale.US, "answered").build(); - // When Validating Interaction Component With All Required Properties + // When Validating Verb with All Required Properties final Set> constraintViolations = validator.validate(verb); // Then ConstraintViolations Size Is Zero @@ -332,7 +332,21 @@ void whenValidatingVerbWithoutIdThenConstraintViolationsSizeIsOne() { final var verb = Verb.builder().addDisplay(Locale.US, "answered").build(); - // When Validating Interaction Component Without Id + // When Validating Verb Component Without Id + final Set> constraintViolations = validator.validate(verb); + + // Then ConstraintViolations Size Is One + assertThat(constraintViolations, hasSize(1)); + + } + + @Test + void whenValidatingVerbWithInvalidDisplayIdThenConstraintViolationsSizeIsOne() { + + final var verb = Verb.builder().id("http://adlnet.gov/expapi/verbs/asked") + .addDisplay(new Locale("unknown"), "answered").build(); + + // When Validating Verb With invalid Display final Set> constraintViolations = validator.validate(verb); // Then ConstraintViolations Size Is One diff --git a/xapi-model/src/test/java/dev/learning/xapi/model/validation/internal/validators/LocaleValidatorTests.java b/xapi-model/src/test/java/dev/learning/xapi/model/validation/internal/validators/LocaleValidatorTests.java new file mode 100644 index 00000000..31dc80a7 --- /dev/null +++ b/xapi-model/src/test/java/dev/learning/xapi/model/validation/internal/validators/LocaleValidatorTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2016-2023 Berry Cloud Ltd. All rights reserved. + */ + +package dev.learning.xapi.model.validation.internal.validators; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Locale; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * LocaleValidator Tests. + * + * @author István Rátkai (Selindek) + */ +@DisplayName("LocaleValidator tests") +class LocaleValidatorTests { + + private static final LocaleValidator validator = new LocaleValidator(); + + @Test + void whenValueIsNullThenResultIsTrue() { + + // When Value Is Null + final var result = validator.isValid(null, null); + + // Then Result Is True + assertTrue(result); + } + + @Test + void whenCallingIsValidOnLocaleWithUKKeyThenResultIsTrue() { + + // When Calling Is Valid On Locale With UK Key + final var result = validator.isValid(Locale.UK, null); + + // Then Result Is True + assertTrue(result); + } + + @Test + void whenCallingIsValidOnLocaleWithENKeyThenResultIsTrue() { + + // When Calling Is Valid On Locale With EN Key + final var result = validator.isValid(Locale.ENGLISH, null); + + // Then Result Is True + assertTrue(result); + } + + @Test + void whenCallingIsValidOnLocaleWithENAndUKKeysThenResultIsTrue() { + + // When Calling Is Valid On Locale With EN And UK Keys + final var result = validator.isValid(Locale.UK, null); + + // Then Result Is True + assertTrue(result); + } + + @Test + void whenCallingIsValidOnLocaleWithENAndUnknownKeysThenResultIsFalse() { + + // When Calling Is Valid On Locale With EN And Unknown Keys + final var result = validator.isValid(Locale.forLanguageTag("unknown"), null); + + // Then Result Is False + assertFalse(result); + } + + @Test + void whenCallingIsValidOnLocaleWithChineseSimplifiedKeyUsingForLangugeTagThenResultIsTrue() { + + // When Calling Is Valid On Locale With Chinese Simplified Key + final var result = validator.isValid(Locale.forLanguageTag("zh-CHS"), null); + + // Then Result Is True + assertTrue(result); + } + + @ParameterizedTest + @ValueSource(strings = {"und", "zh-CHS", "zh-CN", "zh-Hans", "zh-Hant", "zh-HK"}) + void whenCallingIsValidOnLocaleWithValidKeyThenResultIsTrue(String arg) { + + // When Calling Is Valid On Locale With Valid Key + final var result = validator.isValid(new Locale(arg), null); + + // Then Result Is True + assertTrue(result); + } + + @Test + void whenCallingIsValidOnLocaleWithUnknownKeyThenResultIsFalse() { + + // When Calling Is Valid On Locale With Unknown Key + final var result = validator.isValid(new Locale("unknown"), null); + + // Then Result Is False + assertFalse(result); + } + +}