Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for creating entity with content in a single request #1783

Merged
merged 3 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions spring-content-rest/src/main/asciidoc/rest-entitycontent.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
== Entity & Content

=== The Collection Resource
Spring Data REST exposes a collection resource named after the uncapitalized, pluralized version of the domain class the exported repository is handling.

Spring Content REST extends this collection resource with a handler that consumes `multipart/form-data`. This handler allows the creation of an entity
and association of content in a single request.

==== POST
The POST method creates a new entity populating its properties from the form's parameters and associates content from the form's multipart files.

Form parameters may include relationships.

Multipart file names will be used to determine the content property to use in the set content operation.

===== Support Media Types
This POST method supports the `multipart/form-data` media type.
1 change: 1 addition & 0 deletions spring-content-rest/src/main/asciidoc/rest-index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ include::rest-store.adoc[]

include::rest-repository.adoc[]

include::rest-entitycontent.adoc[leveloffset=+1]
include::{spring-content-solr-docs}/solr-rest.adoc[leveloffset=+1]

== Locking and Versioning
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.rest.webmvc.PersistentEntityResourceAssembler;
import org.springframework.data.web.PagedResourcesAssembler;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.hateoas.server.core.EmbeddedWrappers;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

public final class ControllerUtils {
Expand All @@ -19,6 +24,15 @@ public final class ControllerUtils {

private ControllerUtils() {}

public static <R extends RepresentationModel<?>> ResponseEntity<RepresentationModel<?>> toResponseEntity(HttpStatus status, HttpHeaders headers, Optional<R> resource) {
HttpHeaders hdrs = new HttpHeaders();
if (headers != null) {
hdrs.putAll(headers);
}

return new ResponseEntity((RepresentationModel)resource.orElse(null), hdrs, status);
}

protected static CollectionModel<?> entitiesToResources(Page<Object> page,
PagedResourcesAssembler<Object> prAssembler,
PersistentEntityResourceAssembler assembler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import org.springframework.hateoas.server.RepresentationModelProcessor;

@Configuration
@Import(RestConfiguration.class)
@Import({RestConfiguration.class})
public class HypermediaConfiguration {

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.springframework.data.rest.extensions.entitycontent;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
import org.springframework.http.converter.HttpMessageConverter;

import java.util.List;

@Configuration
public class RepositoryEntityMultipartConfiguration {

@Bean
public RepositoryRestConfigurer entityMultipartHttpMessageConverterConfigurer() {
return new RepositoryRestConfigurer() {
public void configureHttpMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
// Add your custom message converter to the list
messageConverters.add(new RepositoryEntityMultipartHttpMessageConverter(messageConverters));
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
package org.springframework.data.rest.extensions.entitycontent;

import internal.org.springframework.content.rest.contentservice.ContentStoreContentService;
import internal.org.springframework.content.rest.controllers.BadRequestException;
import internal.org.springframework.content.rest.controllers.MethodNotAllowedException;
import internal.org.springframework.content.rest.controllers.resolvers.AssociativeStoreResourceResolver;
import internal.org.springframework.content.rest.mappingcontext.ContentPropertyToExportedContext;
import internal.org.springframework.content.rest.mappings.StoreByteRangeHttpRequestHandler;
import internal.org.springframework.content.rest.utils.ControllerUtils;
import internal.org.springframework.content.rest.utils.StoreUtils;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.content.commons.mappingcontext.MappingContext;
import org.springframework.content.commons.property.PropertyPath;
import org.springframework.content.commons.repository.Store;
import org.springframework.content.commons.storeservice.StoreInfo;
import org.springframework.content.commons.storeservice.Stores;
import org.springframework.content.rest.config.RestConfiguration;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.data.repository.support.RepositoryInvokerFactory;
import org.springframework.data.rest.core.mapping.ResourceType;
import org.springframework.data.rest.core.support.SelfLinkProvider;
import org.springframework.data.rest.webmvc.PersistentEntityResource;
import org.springframework.data.rest.webmvc.PersistentEntityResourceAssembler;
import org.springframework.data.rest.webmvc.RepositoryRestController;
import org.springframework.data.rest.webmvc.RootResourceInformation;
import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.hateoas.UriTemplate;
import org.springframework.http.*;
import org.springframework.util.MultiValueMap;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.util.UrlPathHelper;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.security.Principal;
import java.util.*;

@RepositoryRestController
public class RepositoryEntityMultipartController {

private static final String ENTITY_POST_MAPPING = "/{repository}";

private RestConfiguration restConfig;
private RepositoryInvokerFactory repoInvokerFactory;
private MappingContext mappingContext;
private ContentPropertyToExportedContext exportedMappingContext;
private StoreByteRangeHttpRequestHandler byteRangeRestRequestHandler;
private Stores stores;
private SelfLinkProvider selfLinkProvider;

@Autowired
public RepositoryEntityMultipartController(RestConfiguration restConfig, RepositoryInvokerFactory repoInvokerFactory, SelfLinkProvider selfLinkProvider, Stores stores, MappingContext mappingContext, ContentPropertyToExportedContext exportedMappingContext, StoreByteRangeHttpRequestHandler byteRangeRestRequestHandler, @Qualifier("entityMultipartHttpMessageConverterConfigurer") RepositoryRestConfigurer configurer) {
this.restConfig = restConfig;
this.repoInvokerFactory = repoInvokerFactory;
this.selfLinkProvider = selfLinkProvider;
this.stores = stores;
this.mappingContext = mappingContext;
this.exportedMappingContext = exportedMappingContext;
this.byteRangeRestRequestHandler = byteRangeRestRequestHandler;
}

@SuppressWarnings({ "rawtypes", "unchecked" })
@ResponseBody
@PostMapping(value = ENTITY_POST_MAPPING, consumes = "multipart/form-data")
public ResponseEntity<RepresentationModel<?>> createEntityAndContent(RootResourceInformation repoInfo, PersistentEntityResource payload, PersistentEntityResourceAssembler assembler, @PathVariable("repository") String repository, @RequestHeader HttpHeaders headers, MultipartHttpServletRequest req, HttpServletResponse resp)
throws IOException, MethodNotAllowedException, HttpRequestMethodNotSupportedException {

repoInfo.verifySupportedMethod(HttpMethod.POST, ResourceType.COLLECTION);

Class<?> domainType = repoInfo.getDomainType();

Object savedEntity = payload.getContent();

String pathInfo = new UrlPathHelper().getPathWithinApplication(req);
pathInfo = StoreUtils.storeLookupPath(pathInfo, restConfig.getBaseUri());

String[] pathSegments = pathInfo.split("/");
if (pathSegments.length < 2) {
throw new BadRequestException();
}

String store = pathSegments[1];

StoreInfo info = this.stores.getStore(Store.class, StoreUtils.withStorePath(store));
if (info != null) {
ContentStoreContentService service = new ContentStoreContentService(restConfig, info, repoInvokerFactory.getInvokerFor(domainType), mappingContext, exportedMappingContext, byteRangeRestRequestHandler);
MultiValueMap<String, MultipartFile> files = req.getMultiFileMap();
for (String path : files.keySet()) {
MultipartFile file = files.get(path).get(0);

Resource storeResource = new AssociativeStoreResourceResolver(mappingContext).resolve(new InternalWebRequest(req, resp), info, savedEntity, PropertyPath.from(file.getName()));

service.setContent(req, resp, headers, new InputStreamResource(file.getInputStream()), MediaType.parseMediaType(file.getContentType()), storeResource);
}
}

Optional<PersistentEntityResource> resource = Optional.ofNullable(assembler.toFullResource(savedEntity));
headers.setContentType(new MediaType("application", "hal+json"));

String selfLink = selfLinkProvider.createSelfLinkFor(savedEntity).withSelfRel().expand(new Object[0]).getHref();
resp.setHeader("Location", UriTemplate.of(selfLink).expand(new Object[0]).toString());

return ControllerUtils.toResponseEntity(HttpStatus.CREATED, headers, resource);
}

private static class InternalWebRequest implements NativeWebRequest {

private final MultipartHttpServletRequest req;
private final HttpServletResponse resp;

public InternalWebRequest(MultipartHttpServletRequest req, HttpServletResponse resp) {
this.req = req;
this.resp = resp;
}

@Override
public Object getNativeRequest() {
return req;
}

@Override
public Object getNativeResponse() {
return resp;
}

@Override
public <T> T getNativeRequest(Class<T> requiredType) {
return (T) req;
}

@Override
public <T> T getNativeResponse(Class<T> requiredType) {
return (T) resp;
}

@Override
public String getHeader(String headerName) {
return req.getHeader(headerName);
}

@Override
public String[] getHeaderValues(String headerName) {
throw new UnsupportedOperationException();
}

@Override
public Iterator<String> getHeaderNames() {
throw new UnsupportedOperationException();
}

@Override
public String getParameter(String paramName) {
throw new UnsupportedOperationException();
}

@Override
public String[] getParameterValues(String paramName) {
throw new UnsupportedOperationException();
}

@Override
public Iterator<String> getParameterNames() {
throw new UnsupportedOperationException();
}

@Override
public Map<String, String[]> getParameterMap() {
throw new UnsupportedOperationException();
}

@Override
public Locale getLocale() {
throw new UnsupportedOperationException();
}

@Override
public String getContextPath() {
throw new UnsupportedOperationException();
}

@Override
public String getRemoteUser() {
throw new UnsupportedOperationException();
}

@Override
public Principal getUserPrincipal() {
throw new UnsupportedOperationException();
}

@Override
public boolean isUserInRole(String role) {
throw new UnsupportedOperationException();
}

@Override
public boolean isSecure() {
throw new UnsupportedOperationException();
}

@Override
public boolean checkNotModified(long lastModifiedTimestamp) {
throw new UnsupportedOperationException();
}

@Override
public boolean checkNotModified(String etag) {
throw new UnsupportedOperationException();
}

@Override
public boolean checkNotModified(String etag, long lastModifiedTimestamp) {
throw new UnsupportedOperationException();
}

@Override
public String getDescription(boolean includeClientInfo) {
throw new UnsupportedOperationException();
}

@Override
public Object getAttribute(String name, int scope) {
throw new UnsupportedOperationException();
}

@Override
public void setAttribute(String name, Object value, int scope) {
throw new UnsupportedOperationException();
}

@Override
public void removeAttribute(String name, int scope) {
throw new UnsupportedOperationException();
}

@Override
public String[] getAttributeNames(int scope) {
throw new UnsupportedOperationException();
}

@Override
public void registerDestructionCallback(String name, Runnable callback, int scope) {
throw new UnsupportedOperationException();
}

@Override
public Object resolveReference(String key) {
throw new UnsupportedOperationException();
}

@Override
public String getSessionId() {
throw new UnsupportedOperationException();
}

@Override
public Object getSessionMutex() {
throw new UnsupportedOperationException();
}
}
}
Loading
Loading