Skip to content

Commit

Permalink
Merge pull request #248 from xenit-eu/allowed-values-validator
Browse files Browse the repository at this point in the history
Add AllowedValues constraint annotation [ACC-1466]
  • Loading branch information
NielsCW authored Jul 22, 2024
2 parents ad81518 + f4ce5d5 commit d1a527d
Show file tree
Hide file tree
Showing 18 changed files with 312 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,11 @@ MediaTypeConfigurationCustomizer<HalFormsConfiguration> contentGridHalFormsRelat
) {
return new HalFormsRelationFieldOptionsCustomizer(domainTypeMapping, entityLinks);
}

@Bean
MediaTypeConfigurationCustomizer<HalFormsConfiguration> contentGridHalFormsAttributeFieldOptionsCustomizer(
@FormMapping DomainTypeMapping domainTypeMapping
) {
return new HalFormsAttributeFieldOptionsCustomizer(domainTypeMapping);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.contentgrid.spring.data.rest.hal.forms;

import com.contentgrid.spring.data.rest.mapping.Container;
import com.contentgrid.spring.data.rest.mapping.DomainTypeMapping;
import com.contentgrid.spring.data.rest.validation.AllowedValues;
import java.util.concurrent.atomic.AtomicReference;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.hateoas.mediatype.MediaTypeConfigurationCustomizer;
import org.springframework.hateoas.mediatype.hal.forms.HalFormsConfiguration;
import org.springframework.hateoas.mediatype.hal.forms.HalFormsOptions;

@RequiredArgsConstructor
public class HalFormsAttributeFieldOptionsCustomizer implements MediaTypeConfigurationCustomizer<HalFormsConfiguration> {

@NonNull
private final DomainTypeMapping domainTypeMapping;

@Override
public HalFormsConfiguration customize(HalFormsConfiguration configuration) {
for (Class<?> domainType : domainTypeMapping) {
var container = domainTypeMapping.forDomainType(domainType);
configuration = customizeConfiguration(configuration, domainType, container);
}
return configuration;
}

private HalFormsConfiguration customizeConfiguration(HalFormsConfiguration configuration, Class<?> domainType,
Container container) {
var configAtomic = new AtomicReference<>(configuration);
container.doWithProperties(property -> {
property.findAnnotation(AllowedValues.class)
.map(AllowedValues::value)
.ifPresent(options -> configAtomic.updateAndGet(config -> {
return config.withOptions(domainType, property.getName(), metadata -> {
return HalFormsOptions.inline(options);
});
}));
});
return configAtomic.get();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.contentgrid.spring.data.rest.validation;

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 element must have one of the allowed options as value.
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {AllowedValuesValidator.class})
public @interface AllowedValues {

/**
* @return the allowed options of the constraint
*/
String[] value();

/**
* @return the error message template
*/
String message() default "{com.contentgrid.spring.data.rest.validation.AllowedValues.message}";

/**
* @return the groups the constraint belongs to
*/
Class<?>[] groups() default {};

/**
* @return the payload associated to the constraint
*/
Class<? extends Payload>[] payload() default {};

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.contentgrid.spring.data.rest.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.Set;

public class AllowedValuesValidator implements ConstraintValidator<AllowedValues, String> {

private Set<String> options;

@Override
public void initialize(AllowedValues constraintAnnotation) {
this.options = Set.of(constraintAnnotation.value());
}

@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
// Null values are checked by other constraints
return true;
}
return options.contains(value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import org.springframework.hateoas.AffordanceModel.Named;
import org.springframework.hateoas.AffordanceModel.PayloadMetadata;
import org.springframework.hateoas.AffordanceModel.PropertyMetadata;
import org.springframework.hateoas.InputType;
import org.springframework.hateoas.mediatype.InputTypeFactory;
import org.springframework.hateoas.mediatype.html.HtmlInputType;
import org.springframework.http.MediaType;
Expand Down Expand Up @@ -140,17 +141,22 @@ private PropertyMetadata propertyToMetadataForCreateForm(Property property, Clas
}
}

return new BasicPropertyMetadata(path,
property.getTypeInformation().toTypeDescriptor().getResolvableType())
.withRequired(property.isRequired())
.withReadOnly(false);
// Default: use same property metadata as for update form
return propertyToMetadataForUpdateForm(property, domainClass, path);
}

private PropertyMetadata propertyToMetadataForUpdateForm(Property property, Class<?> domainClass, String path) {
return new BasicPropertyMetadata(path,
var result = new BasicPropertyMetadata(path,
property.getTypeInformation().toTypeDescriptor().getResolvableType())
.withRequired(property.isRequired())
.withReadOnly(false);

var inputTypeAnnotation = property.findAnnotation(InputType.class);

if (inputTypeAnnotation.isPresent()) {
return result.withInputType(inputTypeAnnotation.get().value());
}
return result;
}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.contentgrid.spring.data.rest.validation.AllowedValues.message= must be one of {value}
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
package com.contentgrid.spring.data.rest.affordances;

import com.contentgrid.spring.data.support.auditing.v1.AuditMetadata;
import com.contentgrid.spring.test.fixture.invoicing.InvoicingApplication;
import com.contentgrid.spring.test.fixture.invoicing.model.Customer;
import com.contentgrid.spring.test.fixture.invoicing.repository.CustomerRepository;
import com.contentgrid.spring.test.security.WithMockJwt;
import java.util.Set;
import java.util.UUID;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -37,7 +34,7 @@ class AffordanceInjectingSelfLinkProviderTest {

@BeforeEach
void setup() {
customer = customerRepository.save(new Customer(UUID.randomUUID(), 0, new AuditMetadata(), "Abc", "ABC", null, null, null, Set.of(), Set.of()));
customer = customerRepository.save(new Customer("Abc", "ABC"));
}

@AfterEach
Expand Down Expand Up @@ -79,8 +76,15 @@ void templatesAddedOnEntityInstance() throws Exception {
type: "text"
},
{
name: "birthday"
#, type: "datetime"
name: "birthday",
type: "datetime"
},
{
name: "gender",
type: "radio",
options: {
inline: [ "female", "male" ]
}
},
{
name: "total_spend",
Expand Down Expand Up @@ -134,8 +138,15 @@ void templatesAddedOnCollectionResource() throws Exception {
type: "text"
},
{
name: "birthday"
#, type: "datetime"
name: "birthday",
type: "datetime"
},
{
name: "gender",
type: "radio",
options: {
inline: [ "female", "male" ]
}
},
{
name: "total_spend",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.contentgrid.spring.boot.autoconfigure.integration.EventsAutoConfiguration;
import com.contentgrid.spring.data.support.auditing.v1.AuditMetadata;
import com.contentgrid.spring.test.fixture.invoicing.InvoicingApplication;
import com.contentgrid.spring.test.fixture.invoicing.model.Customer;
import com.contentgrid.spring.test.fixture.invoicing.model.Invoice;
Expand Down Expand Up @@ -121,9 +120,7 @@ void setupTestData() {
TIMESTAMP = Instant.now().truncatedTo(ChronoUnit.MILLIS);
Mockito.when(mockedDateTimeProvider.getNow()).thenReturn(Optional.of(TIMESTAMP));

var xenit = customers.save(
new Customer(null, 0, new AuditMetadata(), "XeniT", ORG_XENIT_VAT, null, null, null, new HashSet<>(),
new HashSet<>()));
var xenit = customers.save(new Customer("XeniT", ORG_XENIT_VAT));

XENIT_ID = xenit.getId();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.contentgrid.spring.boot.autoconfigure.integration.EventsAutoConfiguration;
import com.contentgrid.spring.data.support.auditing.v1.AuditMetadata;
import com.contentgrid.spring.test.fixture.invoicing.InvoicingApplication;
import com.contentgrid.spring.test.fixture.invoicing.model.Customer;
import com.contentgrid.spring.test.fixture.invoicing.model.Invoice;
Expand Down Expand Up @@ -85,9 +84,7 @@ String formatInstant(Instant date) {

@BeforeEach
void setupTestData() throws Exception {
var xenit = customers.save(
new Customer(null, 0, new AuditMetadata(), "XeniT", ORG_XENIT_VAT, null, null, null, new HashSet<>(),
new HashSet<>()));
var xenit = customers.save(new Customer("XeniT", ORG_XENIT_VAT));

XENIT_ID = xenit.getId();
CUSTOMER_CONTENT_URL = "/customers/%s/content".formatted(XENIT_ID);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.contentgrid.spring.boot.autoconfigure.integration.EventsAutoConfiguration;
import com.contentgrid.spring.data.support.auditing.v1.AuditMetadata;
import com.contentgrid.spring.test.fixture.invoicing.InvoicingApplication;
import com.contentgrid.spring.test.fixture.invoicing.model.Customer;
import com.contentgrid.spring.test.fixture.invoicing.model.Invoice;
Expand All @@ -28,7 +27,6 @@
import org.apache.commons.lang.StringUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
Expand Down Expand Up @@ -85,9 +83,7 @@ public class OptimisticLockingTest {

@BeforeEach
void setupTestData() {
var xenit = customers.save(
new Customer(null, 0, new AuditMetadata(), "XeniT", ORG_XENIT_VAT, null, null, null, new HashSet<>(),
new HashSet<>()));
var xenit = customers.save(new Customer("XeniT", ORG_XENIT_VAT));

XENIT_ID = xenit.getId();
XENIT_VERSION = xenit.getVersion();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.contentgrid.spring.test.fixture.invoicing.InvoicingApplication;
import com.contentgrid.spring.test.fixture.invoicing.repository.CustomerRepository;
import com.contentgrid.spring.test.security.WithMockJwt;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
Expand All @@ -27,6 +29,14 @@ class ContentGridHalFormsConfigurationTest {
@Autowired
private MockMvc mockMvc;

@Autowired
private CustomerRepository customers;

@AfterEach
void cleanUp() {
customers.deleteAll();
}

@Test
void createFormFieldForToOneRelationLinksToReferredResource() throws Exception {
mockMvc.perform(get("/profile/refunds").accept(MediaTypes.HAL_FORMS_JSON))
Expand Down Expand Up @@ -56,6 +66,29 @@ void createFormFieldForToOneRelationLinksToReferredResource() throws Exception {
);
}

@Test
void createFormFieldForConstrainedAttributeOptions() throws Exception {
mockMvc.perform(get("/profile/customers").accept(MediaTypes.HAL_FORMS_JSON))
.andExpect(content().json("""
{
_templates: {
"create-form": {
properties: [
{
name: "gender",
type: "radio",
options: {
inline: [ "female", "male" ]
}
},
{},{},{},{},{},{}
]
}
}
}
"""));
}

@Test
void linkRelationFormFieldForToManyRelationLinksToReferredResource() throws Exception {
var createdCustomer = mockMvc.perform(
Expand Down Expand Up @@ -111,4 +144,40 @@ void linkRelationFormFieldForToManyRelationLinksToReferredResource() throws Exce
);
}

@Test
void updateFormFieldForConstrainedAttributeOptions() throws Exception {
var createdCustomer = mockMvc.perform(
post("/customers").contentType(MediaType.APPLICATION_JSON).content("""
{
"vat": "BE123"
}
"""))
.andExpect(status().isCreated())
.andReturn()
.getResponse()
.getHeader("Location");

mockMvc.perform(get(createdCustomer).accept(MediaTypes.HAL_FORMS_JSON)).andExpect(
content().json("""
{
_templates: {
default: {
method: "PUT",
properties: [
{
name: "gender",
type: "radio",
options: {
inline: [ "female", "male" ]
}
},
{},{},{},{},{},{}
]
}
}
}
""")
);
}

}
Loading

0 comments on commit d1a527d

Please sign in to comment.