From 183e1fe78deb7b667c60c624ef72740b9f5c1a7e Mon Sep 17 00:00:00 2001 From: Roel Date: Mon, 6 May 2024 17:14:07 +0200 Subject: [PATCH 1/3] Hal Forms for multipart create-and-upload request --- ...entGridDomainTypeMappingConfiguration.java | 10 +- .../html/ContentGridHtmlInputTypeFactory.java | 6 + ...ypeToHalFormsPayloadMetadataConverter.java | 163 +++++++++++------- .../webmvc/HalFormsProfileController.java | 2 +- ...DataProfileLinksResourceProcessorTest.java | 4 + ...aRepositoryLinksResourceProcessorTest.java | 5 + .../HalLinkTitlesAndFormPromptsTest.java | 56 ++++-- ...oHalFormsPayloadMetadataConverterTest.java | 13 +- .../webmvc/HalFormsProfileControllerTest.java | 28 +-- .../invoicing/InvoicingApplicationTests.java | 33 +++- .../test/fixture/invoicing/model/Label.java | 53 ++++++ .../invoicing/repository/LabelRepository.java | 12 ++ .../invoicing/store/LabelContentStore.java | 10 ++ 13 files changed, 291 insertions(+), 104 deletions(-) create mode 100644 contentgrid-spring-data-rest/src/testFixtures/java/com/contentgrid/spring/test/fixture/invoicing/model/Label.java create mode 100644 contentgrid-spring-data-rest/src/testFixtures/java/com/contentgrid/spring/test/fixture/invoicing/repository/LabelRepository.java create mode 100644 contentgrid-spring-data-rest/src/testFixtures/java/com/contentgrid/spring/test/fixture/invoicing/store/LabelContentStore.java diff --git a/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/mapping/ContentGridDomainTypeMappingConfiguration.java b/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/mapping/ContentGridDomainTypeMappingConfiguration.java index 66f87702..7a16f5a7 100644 --- a/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/mapping/ContentGridDomainTypeMappingConfiguration.java +++ b/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/mapping/ContentGridDomainTypeMappingConfiguration.java @@ -7,6 +7,8 @@ import com.contentgrid.spring.data.rest.webmvc.DefaultDomainTypeToHalFormsPayloadMetadataConverter; import com.contentgrid.spring.data.rest.webmvc.DomainTypeToHalFormsPayloadMetadataConverter; import com.contentgrid.spring.querydsl.mapping.CollectionFiltersMapping; +import java.util.Optional; +import org.springframework.content.commons.mappingcontext.MappingContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -36,14 +38,14 @@ DomainTypeMapping halFormsFormMappingDomainTypeMapping(@PlainMapping DomainTypeM @Bean DomainTypeToHalFormsPayloadMetadataConverter DomainTypeToHalFormsPayloadMetadataConverter( @FormMapping DomainTypeMapping formDomainTypeMapping, - CollectionFiltersMapping collectionFiltersMapping + CollectionFiltersMapping collectionFiltersMapping, + Optional contentMappingContext ) { return new DefaultDomainTypeToHalFormsPayloadMetadataConverter( formDomainTypeMapping, - collectionFiltersMapping + collectionFiltersMapping, + contentMappingContext ); } - - } diff --git a/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/mediatype/html/ContentGridHtmlInputTypeFactory.java b/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/mediatype/html/ContentGridHtmlInputTypeFactory.java index 1f68d409..e61892d1 100644 --- a/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/mediatype/html/ContentGridHtmlInputTypeFactory.java +++ b/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/mediatype/html/ContentGridHtmlInputTypeFactory.java @@ -1,5 +1,6 @@ package com.contentgrid.spring.data.rest.mediatype.html; +import java.io.File; import java.time.Instant; import java.time.LocalDate; import lombok.extern.slf4j.Slf4j; @@ -13,6 +14,7 @@ * */ @Slf4j @@ -32,6 +34,10 @@ public String getInputType(Class type) { return "datetime"; } + if (File.class.equals(type)) { + return "file"; + } + if (inputType == null) { log.trace("Type {} not mapped", type.getSimpleName()); return null; diff --git a/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/webmvc/DefaultDomainTypeToHalFormsPayloadMetadataConverter.java b/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/webmvc/DefaultDomainTypeToHalFormsPayloadMetadataConverter.java index 143632d6..864e0676 100644 --- a/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/webmvc/DefaultDomainTypeToHalFormsPayloadMetadataConverter.java +++ b/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/webmvc/DefaultDomainTypeToHalFormsPayloadMetadataConverter.java @@ -5,22 +5,28 @@ import com.contentgrid.spring.data.rest.mapping.Property; import com.contentgrid.spring.querydsl.mapping.CollectionFilter; import com.contentgrid.spring.querydsl.mapping.CollectionFiltersMapping; +import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map.Entry; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Stream; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.NonNull; import lombok.RequiredArgsConstructor; -import lombok.ToString; import lombok.Value; import lombok.With; +import org.springframework.content.commons.annotations.ContentId; +import org.springframework.content.commons.annotations.MimeType; +import org.springframework.content.commons.annotations.OriginalFileName; +import org.springframework.content.commons.mappingcontext.MappingContext; import org.springframework.core.ResolvableType; import org.springframework.core.io.support.SpringFactoriesLoader; import org.springframework.hateoas.AffordanceModel.InputPayloadMetadata; @@ -38,19 +44,31 @@ public class DefaultDomainTypeToHalFormsPayloadMetadataConverter implements private final DomainTypeMapping formMapping; private final CollectionFiltersMapping searchMapping; + // Optional means it only gets autowired if available + private final Optional contentMappingContext; @Override public PayloadMetadata convertToCreatePayloadMetadata(Class domainType) { List properties = new ArrayList<>(); - extractPropertyMetadataForForms(formMapping.forDomainType(domainType)) - .forEachOrdered(properties::add); + extractPropertyMetadataForForms(formMapping.forDomainType(domainType), + domainType, + "", // path prefix starts empty + (prop) -> (!prop.isReadOnly() && !prop.isIgnored() && prop.findAnnotation(MimeType.class).isEmpty() && prop.findAnnotation( + OriginalFileName.class).isEmpty()) || prop.findAnnotation(ContentId.class).isPresent(), + this::propertyToMetadataForCreateForm + ).forEachOrdered(properties::add); return new ClassnameI18nedPayloadMetadata(domainType, properties); } @Override public PayloadMetadata convertToUpdatePayloadMetadata(Class domainType) { List properties = new ArrayList<>(); - extractPropertyMetadataForForms(formMapping.forDomainType(domainType)) + extractPropertyMetadataForForms(formMapping.forDomainType(domainType), + domainType, + "", // path prefix starts empty + (prop) -> (!prop.isReadOnly() && !prop.isIgnored()), + this::propertyToMetadataForUpdateForm + ) .filter(property -> !Objects.equals(property.getInputType(), HtmlInputType.URL_VALUE)) .forEachOrdered(properties::add); return new ClassnameI18nedPayloadMetadata(domainType, properties); @@ -71,87 +89,86 @@ public PayloadMetadata convertToSearchPayloadMetadata(Class domainType) { return new ClassnameI18nedPayloadMetadata(domainType, properties); } - private Stream extractPropertyMetadataForForms(Container entity) { + private Stream extractPropertyMetadataForForms(Container entity, Class domainType, + String pathPrefix, Predicate filter, PropertyMetadataFactory attributeFactory) { var output = Stream.builder(); entity.doWithProperties(new RecursivePropertyConsumer( output, - (property) -> new BasicPropertyMetadata(property.getName(), - property.getTypeInformation().toTypeDescriptor().getResolvableType()) - .withRequired(property.isRequired()) - .withReadOnly(false), - - this::extractPropertyMetadataForForms + attributeFactory, + filter, + domainType, + pathPrefix )); entity.doWithAssociations(new RecursivePropertyConsumer( output, - property -> new BasicPropertyMetadata(property.getName(), + (property, a, b) -> new BasicPropertyMetadata(property.getName(), property.getTypeInformation().toTypeDescriptor().getResolvableType()) .withInputType(HtmlInputType.URL_VALUE) .withRequired(property.isRequired()) .withReadOnly(false), - this::extractPropertyMetadataForForms + filter, + domainType, + pathPrefix )); return output.build(); } - @RequiredArgsConstructor - private static class RecursivePropertyConsumer implements Consumer { - private final Consumer output; - private final Function factory; - private final Function> recursor; + private PropertyMetadata propertyToMetadataForCreateForm(Property property, Class domainClass, String path) { - @Override - public void accept(Property property) { - if (property.isIgnored() || property.isReadOnly()) { - return; - } + var contentPropertyKey = contentMappingContext.flatMap(context -> context.getContentPropertyMap(domainClass) + .entrySet() + .stream() + .filter(entry -> Objects.equals(entry.getValue().getContentIdPropertyPath(), underscoreToCamelCase(path))) + .findFirst() + .map(Entry::getKey)); - property.nestedContainer().ifPresentOrElse(container -> { - recursor.apply(container) - .map(propertyMetadata -> new PrefixedPropertyMetadata(property.getName(), propertyMetadata)) - .forEachOrdered(output); - }, () -> { - output.accept(factory.apply(property)); - }); + if (contentPropertyKey.isPresent()) { + return new BasicPropertyMetadata(contentPropertyKey.get(), ResolvableType.forClass(File.class)) + .withRequired(property.isRequired()) + .withReadOnly(false); } - } - @RequiredArgsConstructor - @ToString - private static class PrefixedPropertyMetadata implements PropertyMetadata { + return new BasicPropertyMetadata(path, + property.getTypeInformation().toTypeDescriptor().getResolvableType()) + .withRequired(property.isRequired()) + .withReadOnly(false); + }; - private final String prefix; - private final PropertyMetadata delegate; + private PropertyMetadata propertyToMetadataForUpdateForm(Property property, Class domainClass, String path) { + return new BasicPropertyMetadata(path, + property.getTypeInformation().toTypeDescriptor().getResolvableType()) + .withRequired(property.isRequired()) + .withReadOnly(false); + }; - @Override - public String getName() { - return prefix + "." + delegate.getName(); - } - @Override - public boolean isRequired() { - return delegate.isRequired(); - } + @RequiredArgsConstructor + private class RecursivePropertyConsumer implements Consumer { + private final Consumer output; + private final PropertyMetadataFactory factory; + private final Predicate filter; - @Override - public boolean isReadOnly() { - return delegate.isReadOnly(); - } + private final Class domainType; + private final String pathPrefix; - @Override - public Optional getPattern() { - return delegate.getPattern(); - } @Override - public ResolvableType getType() { - return delegate.getType(); - } + public void accept(Property property) { + if (!filter.test(property)) { + return; + } - @Override - public String getInputType() { - return delegate.getInputType(); + String path = pathPrefix.length() == 0 + ? property.getName() + : pathPrefix + "." + property.getName(); + + property.nestedContainer().ifPresentOrElse(container -> { + extractPropertyMetadataForForms(container, domainType, path, filter, factory) + .forEachOrdered(output); + }, () -> { + output.accept(factory.build(property, domainType, path)); + }); } } @@ -227,4 +244,34 @@ public Class getType() { } } + @FunctionalInterface + static interface PropertyMetadataFactory { + PropertyMetadata build(Property property, Class domainClass, String path); + } + + private static String underscoreToCamelCase(String underscored) { + int index = underscored.indexOf('_'); + if (index == -1) { + return underscored; + } + + int from = 0; + StringBuilder camel = new StringBuilder(); + while (index != -1) { + camel.append(underscored, from, index); + if (index + 1 < underscored.length()) { + camel.append((char) (underscored.charAt(index + 1) - 32)); + from = index + 2; + } else { + from = index + 1; + break; + } + index = underscored.indexOf('_', from); + } + + camel.append(underscored.substring(from)); + + return camel.toString(); + } + } diff --git a/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/webmvc/HalFormsProfileController.java b/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/webmvc/HalFormsProfileController.java index 9530d2d3..0cadbddb 100644 --- a/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/webmvc/HalFormsProfileController.java +++ b/contentgrid-spring-data-rest/src/main/java/com/contentgrid/spring/data/rest/webmvc/HalFormsProfileController.java @@ -98,7 +98,7 @@ void halFormsProfile(RootResourceInformation information, HttpServletResponse re .andAfford(HttpMethod.POST) .withName(IanaLinkRelations.CREATE_FORM_VALUE) .withInput(toHalFormsPayloadMetadataConverter.convertToCreatePayloadMetadata(information.getDomainType())) - .withInputMediaType(MediaType.APPLICATION_JSON) + .withInputMediaType(MediaType.MULTIPART_FORM_DATA) .andAfford(HttpMethod.PATCH) // This gets mapped to "GET" with the very ugly hack below .withName(IanaLinkRelations.SEARCH_VALUE) .withInput(toHalFormsPayloadMetadataConverter.convertToSearchPayloadMetadata(information.getDomainType())) diff --git a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/links/SpringDataProfileLinksResourceProcessorTest.java b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/links/SpringDataProfileLinksResourceProcessorTest.java index 88306d23..784a082e 100644 --- a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/links/SpringDataProfileLinksResourceProcessorTest.java +++ b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/links/SpringDataProfileLinksResourceProcessorTest.java @@ -49,6 +49,10 @@ void entityLinkRelAddedToProfile() throws Exception { name: "shipping-addresses", href: "http://localhost/profile/shipping-addresses" }, + { + name: "labels", + href: "http://localhost/profile/labels" + }, { name: "refunds", href: "http://localhost/profile/refunds" diff --git a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/links/SpringDataRepositoryLinksResourceProcessorTest.java b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/links/SpringDataRepositoryLinksResourceProcessorTest.java index 06bddbd2..152fe6bc 100644 --- a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/links/SpringDataRepositoryLinksResourceProcessorTest.java +++ b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/links/SpringDataRepositoryLinksResourceProcessorTest.java @@ -54,6 +54,11 @@ void entityLinkRelAddedToRoot() throws Exception { href: "http://localhost/shipping-addresses{?page,size,sort}", templated: true }, + { + name: "labels", + href: "http://localhost/labels{?page,size,sort}", + templated: true + }, { name: "refunds", href: "http://localhost/refunds{?page,size,sort}", diff --git a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/messages/HalLinkTitlesAndFormPromptsTest.java b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/messages/HalLinkTitlesAndFormPromptsTest.java index e85aadd2..64ce3cf6 100644 --- a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/messages/HalLinkTitlesAndFormPromptsTest.java +++ b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/messages/HalLinkTitlesAndFormPromptsTest.java @@ -4,8 +4,10 @@ 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; +import com.contentgrid.spring.test.fixture.invoicing.model.Label; import com.contentgrid.spring.test.fixture.invoicing.repository.CustomerRepository; import com.contentgrid.spring.test.fixture.invoicing.repository.InvoiceRepository; +import com.contentgrid.spring.test.fixture.invoicing.repository.LabelRepository; import com.contentgrid.spring.test.security.WithMockJwt; import java.util.Set; import java.util.UUID; @@ -35,14 +37,18 @@ class HalLinkTitlesAndFormPromptsTest { CustomerRepository customerRepository; @Autowired InvoiceRepository invoiceRepository; + @Autowired + LabelRepository labelRepository; Customer customer; Invoice invoice; + Label label; @BeforeEach void setup() { customer = customerRepository.save(new Customer(UUID.randomUUID(), 0, new AuditMetadata(), "Abc", "ABC", null, null, null, Set.of(), Set.of())); invoice = invoiceRepository.save(new Invoice("12345678", true, true, customer, Set.of())); + label = labelRepository.save(new Label(UUID.randomUUID(), "here", "there", null)); } @AfterEach @@ -101,16 +107,44 @@ void promptOnCreateFormPropertiesInHalForms() throws Exception { type : "number" }, { - prompt: "Customer Document Mimetype", - name: "content.mimetype", - type: "text" + name: "content", + type: "file" + }, + { name : "orders", type : "url" }, { name : "invoices", type : "url" } + ] + } + } + } + """)) + ; + } + + @Test + void contentFieldCamelCasedInCreateForm() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/profile/labels") + .accept(MediaTypes.HAL_FORMS_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(""" + { + _templates: { + search: {}, + create-form: { + method: "POST", + properties: [ + { + name: "from", + type: "text", + required: true }, { - prompt: "Customer Document Filename", - name: "content.filename", - type: "text" + name: "to", + type: "text", + required: true }, - { name : "orders", type : "url" }, { name : "invoices", type : "url" } + { + name: "barcodePicture", + type: "file" + } ] } } @@ -132,7 +166,7 @@ void titleOnCgEntityInHalForms() throws Exception { title: "Client" }, { name: "invoices" }, { name: "refunds" }, { name: "promotions" }, - { name: "shipping-addresses" }, { name: "orders" } + { name: "shipping-addresses" }, { name: "labels" }, { name: "orders" } ] } } @@ -182,13 +216,13 @@ void titleOnCgContentInHalForms() throws Exception { @Test void promptOnCgContentPropertiesInHalForms() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.get("/profile/invoices").accept(MediaTypes.HAL_FORMS_JSON)) + mockMvc.perform(MockMvcRequestBuilders.get("/invoices/" + invoice.getId()).accept(MediaTypes.HAL_FORMS_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()) .andDo(res -> System.out.println(res.getResponse().getContentAsString())) .andExpect(MockMvcResultMatchers.content().json(""" { _templates: { - "create-form": { + "default": { properties: [ { prompt: "Attached File Filename", @@ -200,7 +234,7 @@ void promptOnCgContentPropertiesInHalForms() throws Exception { name: "attachment_mimetype", type: "text" }, - {},{},{},{},{},{},{},{} + {},{},{},{},{} ] } } diff --git a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/webmvc/DefaultDomainTypeToHalFormsPayloadMetadataConverterTest.java b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/webmvc/DefaultDomainTypeToHalFormsPayloadMetadataConverterTest.java index d04838d6..28043ddc 100644 --- a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/webmvc/DefaultDomainTypeToHalFormsPayloadMetadataConverterTest.java +++ b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/webmvc/DefaultDomainTypeToHalFormsPayloadMetadataConverterTest.java @@ -39,15 +39,10 @@ void convertToCreatePayloadMetadata_embeddedContent() { assertThat(vat.isReadOnly()).isFalse(); assertThat(vat.isRequired()).isTrue(); }, - contentMimetype -> { - assertThat(contentMimetype.getName()).isEqualTo("content.mimetype"); - assertThat(contentMimetype.isReadOnly()).isFalse(); - assertThat(contentMimetype.isRequired()).isFalse(); - }, - contentFilename -> { - assertThat(contentFilename.getName()).isEqualTo("content.filename"); - assertThat(contentFilename.isReadOnly()).isFalse(); - assertThat(contentFilename.isRequired()).isFalse(); + content -> { + assertThat(content.getName()).isEqualTo("content"); + assertThat(content.isReadOnly()).isFalse(); + assertThat(content.isRequired()).isFalse(); }, birthday -> { assertThat(birthday.getName()).isEqualTo("birthday"); diff --git a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/webmvc/HalFormsProfileControllerTest.java b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/webmvc/HalFormsProfileControllerTest.java index f7d3bdb5..971a9c35 100644 --- a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/webmvc/HalFormsProfileControllerTest.java +++ b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/data/rest/webmvc/HalFormsProfileControllerTest.java @@ -47,7 +47,7 @@ void profileController_embeddedContent() throws Exception { _templates: { "create-form": { method: "POST", - contentType: "application/json", + contentType: "multipart/form-data", target: "http://localhost/customers", properties: [ { @@ -60,12 +60,8 @@ void profileController_embeddedContent() throws Exception { type: "text" }, { - name: "content.mimetype", - type: "text" - }, - { - name: "content.filename", - type: "text" + name: "content", + type: "file" }, { name: "birthday", @@ -167,7 +163,7 @@ void profileController_requiredAssociation() throws Exception { _templates: { "create-form": { method: "POST", - contentType: "application/json", + contentType: "multipart/form-data", target: "http://localhost/invoices", properties: [ { @@ -184,20 +180,12 @@ void profileController_requiredAssociation() throws Exception { # ,type: "checkbox" }, { - name: "content_mimetype", - type: "text" - }, - { - name: "content_filename", - type: "text" + name: "content", + type: "file" }, { - name: "attachment_mimetype", - type: "text" - }, - { - name: "attachment_filename", - type: "text" + name: "attachment", + type: "file" }, { name: "counterparty", diff --git a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/test/fixture/invoicing/InvoicingApplicationTests.java b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/test/fixture/invoicing/InvoicingApplicationTests.java index 0695443f..426cea86 100644 --- a/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/test/fixture/invoicing/InvoicingApplicationTests.java +++ b/contentgrid-spring-data-rest/src/test/java/com/contentgrid/spring/test/fixture/invoicing/InvoicingApplicationTests.java @@ -19,6 +19,7 @@ import com.contentgrid.spring.data.support.auditing.v1.AuditMetadata; import com.contentgrid.spring.test.fixture.invoicing.model.Customer; import com.contentgrid.spring.test.fixture.invoicing.model.Invoice; +import com.contentgrid.spring.test.fixture.invoicing.model.Label; import com.contentgrid.spring.test.fixture.invoicing.model.Order; import com.contentgrid.spring.test.fixture.invoicing.model.PromotionCampaign; import com.contentgrid.spring.test.fixture.invoicing.model.ShippingAddress; @@ -27,8 +28,10 @@ import com.contentgrid.spring.test.fixture.invoicing.repository.OrderRepository; import com.contentgrid.spring.test.fixture.invoicing.repository.PromotionCampaignRepository; import com.contentgrid.spring.test.fixture.invoicing.repository.ShippingAddressRepository; +import com.contentgrid.spring.test.fixture.invoicing.repository.LabelRepository; import com.contentgrid.spring.test.fixture.invoicing.store.CustomerContentStore; import com.contentgrid.spring.test.fixture.invoicing.store.InvoiceContentStore; +import com.contentgrid.spring.test.fixture.invoicing.store.LabelContentStore; import com.contentgrid.spring.test.security.WithMockJwt; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; @@ -130,12 +133,18 @@ class InvoicingApplicationTests { @Autowired ShippingAddressRepository shippingAddresses; + @Autowired + LabelRepository labels; + @Autowired InvoiceContentStore invoicesContent; @Autowired CustomerContentStore customersContent; + @Autowired + LabelContentStore labelsContent; + @Autowired PlatformTransactionManager transactionManager; @@ -198,7 +207,6 @@ void setupTestData() { new Invoice(INVOICE_NUMBER_1, true, false, xenit, new HashSet<>(List.of(order1, order2)))).getId(); INVOICE_2_ID = invoices.save(new Invoice(INVOICE_NUMBER_2, false, true, inbev, new HashSet<>(List.of(order3)))) .getId(); - } @AfterEach @@ -208,6 +216,7 @@ void cleanupTestData() { shippingAddresses.deleteAll(); customers.deleteAll(); promos.deleteAll(); + labels.deleteAll(); } private Matcher curies() { @@ -1778,6 +1787,28 @@ void postMultipartEntityAndContent_textPlainUtf8_http201() throws Exception { assertThat(customer.getContent().getFilename()).isEqualTo(file.getOriginalFilename()); } + @Test + void postMultipartEntityAndContent_camelCaseFieldName_http201() throws Exception { + var file = new MockMultipartFile("barcodePicture", "barcode.jpg", MIMETYPE_PLAINTEXT_UTF8, + UNICODE_TEXT.getBytes(StandardCharsets.UTF_8)); + + mockMvc.perform(multipart(HttpMethod.POST, "/labels") + .file(file) + .param("from", "here") + .param("to", "there")) + .andExpect(status().isCreated()); + + var label = labels.findAll().get(0); + + assertThat(labelsContent.getContent(label, PropertyPath.from("barcodePicture"))) + .hasContent(UNICODE_TEXT); + assertThat(label.getBarcodePicture()).isNotNull(); + assertThat(label.getBarcodePicture().getId()).isNotBlank(); + assertThat(label.getBarcodePicture().getMimetype()).isEqualTo(MIMETYPE_PLAINTEXT_UTF8); + assertThat(label.getBarcodePicture().getLength()).isEqualTo(UNICODE_TEXT_UTF8_LENGTH); + assertThat(label.getBarcodePicture().getFilename()).isEqualTo(file.getOriginalFilename()); + } + } @Nested diff --git a/contentgrid-spring-data-rest/src/testFixtures/java/com/contentgrid/spring/test/fixture/invoicing/model/Label.java b/contentgrid-spring-data-rest/src/testFixtures/java/com/contentgrid/spring/test/fixture/invoicing/model/Label.java new file mode 100644 index 00000000..573a8621 --- /dev/null +++ b/contentgrid-spring-data-rest/src/testFixtures/java/com/contentgrid/spring/test/fixture/invoicing/model/Label.java @@ -0,0 +1,53 @@ +package com.contentgrid.spring.test.fixture.invoicing.model; + +import com.contentgrid.spring.querydsl.annotation.CollectionFilterParam; +import com.contentgrid.spring.querydsl.predicate.EqualsIgnoreCase; +import com.contentgrid.spring.test.fixture.invoicing.model.support.Content; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonProperty.Access; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.content.rest.RestResource; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Label { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @JsonProperty(access = Access.READ_ONLY) + private UUID id; + + @Column(nullable = false) + @CollectionFilterParam(predicate = EqualsIgnoreCase.class) + @NotNull + private String from; + + @Column(nullable = false) + @CollectionFilterParam(predicate = EqualsIgnoreCase.class) + @NotNull + private String to; + + @Embedded + @AttributeOverride(name = "id", column = @Column(name = "barcode_picture__id")) + @AttributeOverride(name = "length", column = @Column(name = "barcode_picture__length")) + @AttributeOverride(name = "mimetype", column = @Column(name = "barcode_picture__mimetype")) + @AttributeOverride(name = "filename", column = @Column(name = "barcode_picture__filename")) + @RestResource(linkRel = "d:barcode_picture", path = "barcode_picture") + @JsonProperty("barcode_picture") + private Content barcodePicture; +} diff --git a/contentgrid-spring-data-rest/src/testFixtures/java/com/contentgrid/spring/test/fixture/invoicing/repository/LabelRepository.java b/contentgrid-spring-data-rest/src/testFixtures/java/com/contentgrid/spring/test/fixture/invoicing/repository/LabelRepository.java new file mode 100644 index 00000000..5f14ffac --- /dev/null +++ b/contentgrid-spring-data-rest/src/testFixtures/java/com/contentgrid/spring/test/fixture/invoicing/repository/LabelRepository.java @@ -0,0 +1,12 @@ +package com.contentgrid.spring.test.fixture.invoicing.repository; + +import com.contentgrid.spring.test.fixture.invoicing.model.Label; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +@RepositoryRestResource(path = "labels", itemResourceRel = "d:label", collectionResourceRel = "d:labels") +public interface LabelRepository extends JpaRepository, QuerydslPredicateExecutor