Skip to content

Commit

Permalink
Merge pull request #224 from xenit-eu/hal-forms-create-and-upload
Browse files Browse the repository at this point in the history
Hal Forms for multipart create-and-upload request
  • Loading branch information
rschev authored May 7, 2024
2 parents 541ce46 + acf9a32 commit 0f85527
Show file tree
Hide file tree
Showing 13 changed files with 337 additions and 103 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,14 +38,14 @@ DomainTypeMapping halFormsFormMappingDomainTypeMapping(@PlainMapping DomainTypeM
@Bean
DomainTypeToHalFormsPayloadMetadataConverter DomainTypeToHalFormsPayloadMetadataConverter(
@FormMapping DomainTypeMapping formDomainTypeMapping,
CollectionFiltersMapping collectionFiltersMapping
CollectionFiltersMapping collectionFiltersMapping,
Optional<MappingContext> contentMappingContext
) {
return new DefaultDomainTypeToHalFormsPayloadMetadataConverter(
formDomainTypeMapping,
collectionFiltersMapping
collectionFiltersMapping,
contentMappingContext
);
}



}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,6 +14,7 @@
* <ul>
* <li>{@link Boolean} maps to {@code checkbox}</li>
* <li>{@link Instant} maps to {@code datetime}</li>
* <li>Content maps to {@code file}</li>
* </ul>
*/
@Slf4j
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,29 @@
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.apache.commons.lang.CharUtils;
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;
Expand All @@ -31,26 +38,39 @@
import org.springframework.hateoas.mediatype.html.HtmlInputType;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.StringUtils;

@RequiredArgsConstructor
public class DefaultDomainTypeToHalFormsPayloadMetadataConverter implements
DomainTypeToHalFormsPayloadMetadataConverter {

private final DomainTypeMapping formMapping;
private final CollectionFiltersMapping searchMapping;
// Optional means it only gets autowired if available
private final Optional<MappingContext> contentMappingContext;

@Override
public PayloadMetadata convertToCreatePayloadMetadata(Class<?> domainType) {
List<PropertyMetadata> 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<PropertyMetadata> 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);
Expand All @@ -71,87 +91,86 @@ public PayloadMetadata convertToSearchPayloadMetadata(Class<?> domainType) {
return new ClassnameI18nedPayloadMetadata(domainType, properties);
}

private Stream<PropertyMetadata> extractPropertyMetadataForForms(Container entity) {
private Stream<PropertyMetadata> extractPropertyMetadataForForms(Container entity, Class<?> domainType,
String pathPrefix, Predicate<Property> filter, PropertyMetadataFactory attributeFactory) {
var output = Stream.<PropertyMetadata>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<Property> {
private final Consumer<PropertyMetadata> output;
private final Function<Property, PropertyMetadata> factory;
private final Function<Container, Stream<PropertyMetadata>> 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 -> pathMatches(entry.getValue().getContentIdPropertyPath(), 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);
}

return new BasicPropertyMetadata(path,
property.getTypeInformation().toTypeDescriptor().getResolvableType())
.withRequired(property.isRequired())
.withReadOnly(false);
}

@RequiredArgsConstructor
@ToString
private static class PrefixedPropertyMetadata implements PropertyMetadata {
private PropertyMetadata propertyToMetadataForUpdateForm(Property property, Class<?> domainClass, String path) {
return new BasicPropertyMetadata(path,
property.getTypeInformation().toTypeDescriptor().getResolvableType())
.withRequired(property.isRequired())
.withReadOnly(false);
}

private final String prefix;
private final PropertyMetadata delegate;

@Override
public String getName() {
return prefix + "." + delegate.getName();
}
@RequiredArgsConstructor
private class RecursivePropertyConsumer implements Consumer<Property> {
private final Consumer<PropertyMetadata> output;
private final PropertyMetadataFactory factory;
private final Predicate<Property> filter;

@Override
public boolean isRequired() {
return delegate.isRequired();
}
private final Class<?> domainType;
private final String pathPrefix;

@Override
public boolean isReadOnly() {
return delegate.isReadOnly();
}

@Override
public Optional<String> getPattern() {
return delegate.getPattern();
}
public void accept(Property property) {
if (!filter.test(property)) {
return;
}

@Override
public ResolvableType getType() {
return delegate.getType();
}
String path = pathPrefix.length() == 0
? property.getName()
: pathPrefix + "." + property.getName();

@Override
public String getInputType() {
return delegate.getInputType();
property.nestedContainer().ifPresentOrElse(container -> {
extractPropertyMetadataForForms(container, domainType, path, filter, factory)
.forEachOrdered(output);
}, () -> {
output.accept(factory.build(property, domainType, path));
});
}
}

Expand Down Expand Up @@ -227,4 +246,43 @@ public Class<?> getType() {
}
}

@FunctionalInterface
static interface PropertyMetadataFactory {
PropertyMetadata build(Property property, Class<?> domainClass, String path);
}

private static boolean pathMatches(String contentIdPropertyPath, String givenPath) {
return Objects.equals(
// names that are reserved keywords, like "public", have a leading _ in their java property
StringUtils.trimLeadingCharacter(contentIdPropertyPath, '_'),
// to match the java field name, we must convert the snake_case from the given path to camelCase
underscoreToCamelCase(givenPath)
);
}

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(Character.toUpperCase(underscored.charAt(index + 1)));
from = index + 2;
} else {
from = index + 1;
break;
}
index = underscored.indexOf('_', from);
}

camel.append(underscored.substring(from));

return camel.toString();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ void entityLinkRelAddedToProfile() throws Exception {
name: "shipping-addresses",
href: "http://localhost/profile/shipping-addresses"
},
{
name: "shipping-labels",
href: "http://localhost/profile/shipping-labels"
},
{
name: "refunds",
href: "http://localhost/profile/refunds"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ void entityLinkRelAddedToRoot() throws Exception {
href: "http://localhost/shipping-addresses{?page,size,sort}",
templated: true
},
{
name: "shipping-labels",
href: "http://localhost/shipping-labels{?page,size,sort}",
templated: true
},
{
name: "refunds",
href: "http://localhost/refunds{?page,size,sort}",
Expand Down
Loading

0 comments on commit 0f85527

Please sign in to comment.