From 840c1ba9693ec7c6783b9fde6febecee25a6656b Mon Sep 17 00:00:00 2001 From: Jamal Kaabi-Mofrad Date: Tue, 10 May 2016 10:30:20 +0000 Subject: [PATCH] Merged FILE-FOLDER-API (5.2.0) to HEAD (5.2) 119504 jkaabimofrad: RA-637, SFS-260: Added multipart upload REST API. git-svn-id: https://svn.alfresco.com/repos/alfresco-enterprise/alfresco/HEAD/root@126357 c4b6b30b-aa2e-2d43-bbcb-ca4b014f7261 --- .../rest-framework-messages.properties | 1 + config/alfresco/public-rest-context.xml | 3 +- source/java/org/alfresco/rest/api/Nodes.java | 21 +- .../org/alfresco/rest/api/impl/NodesImpl.java | 310 +++++++++++++++++- .../rest/api/nodes/NodeChildrenRelation.java | 21 +- .../RequestEntityTooLargeException.java | 40 +++ .../alfresco/rest/api/tests/NodeApiTest.java | 211 +++++++++++- .../rest/api/tests/util/MultiPartBuilder.java | 10 +- .../publicapi/upload/quick-1.txt | 1 + .../publicapi/upload/quick-2.txt | 2 + .../test-resources/publicapi/upload/quick.pdf | Bin 0 -> 23697 bytes 11 files changed, 589 insertions(+), 31 deletions(-) create mode 100644 source/java/org/alfresco/rest/framework/core/exceptions/RequestEntityTooLargeException.java create mode 100644 source/test-resources/publicapi/upload/quick-1.txt create mode 100644 source/test-resources/publicapi/upload/quick-2.txt create mode 100644 source/test-resources/publicapi/upload/quick.pdf diff --git a/config/alfresco/messages/rest-framework-messages.properties b/config/alfresco/messages/rest-framework-messages.properties index b0de361dd4..024740621f 100644 --- a/config/alfresco/messages/rest-framework-messages.properties +++ b/config/alfresco/messages/rest-framework-messages.properties @@ -11,4 +11,5 @@ framework.exception.PermissionDenied=Permission was denied framework.exception.StaleEntity=Attempt to update a stale entity framework.exception.UnsupportedResourceOperation=The operation is unsupported framework.exception.DeletedResource=In this version of the API resource {0} has been deleted +framework.exception.RequestEntityTooLarge=Request entity too large diff --git a/config/alfresco/public-rest-context.xml b/config/alfresco/public-rest-context.xml index 8f53dd0e32..a2d7488a76 100644 --- a/config/alfresco/public-rest-context.xml +++ b/config/alfresco/public-rest-context.xml @@ -137,7 +137,8 @@ - + + diff --git a/source/java/org/alfresco/rest/api/Nodes.java b/source/java/org/alfresco/rest/api/Nodes.java index 075282251f..707754c5ed 100644 --- a/source/java/org/alfresco/rest/api/Nodes.java +++ b/source/java/org/alfresco/rest/api/Nodes.java @@ -31,6 +31,7 @@ import org.alfresco.rest.framework.resource.parameters.Parameters; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.StoreRef; import org.alfresco.service.namespace.QName; +import org.springframework.extensions.webscripts.servlet.FormData; /** * @author steveglover @@ -38,11 +39,11 @@ import org.alfresco.service.namespace.QName; */ public interface Nodes { - NodeRef validateNode(StoreRef storeRef, String nodeId); - NodeRef validateNode(String nodeId); - NodeRef validateNode(NodeRef nodeRef); - boolean nodeMatches(NodeRef nodeRef, Set expectedTypes, Set excludedTypes); - + NodeRef validateNode(StoreRef storeRef, String nodeId); + NodeRef validateNode(String nodeId); + NodeRef validateNode(NodeRef nodeRef); + boolean nodeMatches(NodeRef nodeRef, Set expectedTypes, Set excludedTypes); + /** * Get the node representation for the given node. * @param nodeId String @@ -115,4 +116,14 @@ public interface Nodes // TODO update REST fwk - to optionally support return of json void updateContent(String fileNodeId, BasicContentInfo contentInfo, InputStream stream, Parameters parameters); + + /** + * Uploads file content and meta-data into the repository. + * + * @param parentFolderNodeId String id of parent folder node or well-known alias, eg. "-root-" or "-my-" + * @param formData the {@link FormData} + * @param parameters the {@link Parameters} object to get the parameters passed into the request + * @return {@code Node} if successful + */ + Node upload(String parentFolderNodeId, FormData formData, Parameters parameters); } diff --git a/source/java/org/alfresco/rest/api/impl/NodesImpl.java b/source/java/org/alfresco/rest/api/impl/NodesImpl.java index 08b71ee459..026ce673b0 100644 --- a/source/java/org/alfresco/rest/api/impl/NodesImpl.java +++ b/source/java/org/alfresco/rest/api/impl/NodesImpl.java @@ -21,8 +21,10 @@ package org.alfresco.rest.api.impl; import org.alfresco.model.ContentModel; import org.alfresco.query.PagingRequest; import org.alfresco.query.PagingResults; +import org.alfresco.repo.content.ContentLimitViolationException; import org.alfresco.repo.model.Repository; import org.alfresco.repo.node.getchildren.GetChildrenCannedQuery; +import org.alfresco.repo.security.permissions.AccessDeniedException; import org.alfresco.rest.antlr.WhereClauseParser; import org.alfresco.rest.api.Nodes; import org.alfresco.rest.api.model.Document; @@ -31,8 +33,12 @@ import org.alfresco.rest.api.model.Node; import org.alfresco.rest.api.model.PathInfo; import org.alfresco.rest.api.model.PathInfo.ElementInfo; import org.alfresco.rest.api.model.UserInfo; +import org.alfresco.rest.framework.core.exceptions.ApiException; +import org.alfresco.rest.framework.core.exceptions.ConstraintViolatedException; import org.alfresco.rest.framework.core.exceptions.EntityNotFoundException; import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException; +import org.alfresco.rest.framework.core.exceptions.PermissionDeniedException; +import org.alfresco.rest.framework.core.exceptions.RequestEntityTooLargeException; import org.alfresco.rest.framework.resource.content.BasicContentInfo; import org.alfresco.rest.framework.resource.content.BinaryResource; import org.alfresco.rest.framework.resource.content.NodeBinaryResource; @@ -44,14 +50,19 @@ import org.alfresco.rest.framework.resource.parameters.where.Query; import org.alfresco.rest.framework.resource.parameters.where.QueryHelper; import org.alfresco.rest.workflow.api.impl.MapBasedQueryWalker; import org.alfresco.service.ServiceRegistry; +import org.alfresco.service.cmr.action.Action; +import org.alfresco.service.cmr.action.ActionDefinition; +import org.alfresco.service.cmr.action.ActionService; import org.alfresco.service.cmr.dictionary.DictionaryService; import org.alfresco.service.cmr.model.FileFolderService; import org.alfresco.service.cmr.model.FileInfo; import org.alfresco.service.cmr.model.FileNotFoundException; import org.alfresco.service.cmr.repository.ChildAssociationRef; import org.alfresco.service.cmr.repository.ContentData; +import org.alfresco.service.cmr.repository.ContentService; import org.alfresco.service.cmr.repository.ContentWriter; import org.alfresco.service.cmr.repository.InvalidNodeRefException; +import org.alfresco.service.cmr.repository.MimetypeService; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.repository.Path; @@ -59,13 +70,23 @@ import org.alfresco.service.cmr.repository.Path.Element; import org.alfresco.service.cmr.repository.StoreRef; import org.alfresco.service.cmr.security.AccessStatus; import org.alfresco.service.cmr.security.PermissionService; +import org.alfresco.service.cmr.usage.ContentQuotaException; +import org.alfresco.service.cmr.version.VersionService; import org.alfresco.service.namespace.NamespaceService; import org.alfresco.service.namespace.QName; import org.alfresco.util.Pair; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.extensions.surf.util.Content; +import org.springframework.extensions.webscripts.servlet.FormData; +import java.io.BufferedInputStream; +import java.io.IOException; import java.io.InputStream; import java.io.Serializable; import java.math.BigInteger; +import java.nio.charset.Charset; import java.util.AbstractList; import java.util.ArrayList; import java.util.Arrays; @@ -88,6 +109,8 @@ import java.util.Set; */ public class NodesImpl implements Nodes { + private static final Log logger = LogFactory.getLog(NodesImpl.class); + private static enum Type { // Note: ordered @@ -107,6 +130,10 @@ public class NodesImpl implements Nodes private FileFolderService fileFolderService; private NamespaceService namespaceService; private PermissionService permissionService; + private MimetypeService mimetypeService; + private ContentService contentService; + private ActionService actionService; + private VersionService versionService; private Repository repositoryHelper; private ServiceRegistry sr; private Set defaultIgnoreTypes; @@ -114,6 +141,16 @@ public class NodesImpl implements Nodes public void init() { + this.namespaceService = sr.getNamespaceService(); + this.fileFolderService = sr.getFileFolderService(); + this.nodeService = sr.getNodeService(); + this.permissionService = sr.getPermissionService(); + this.dictionaryService = sr.getDictionaryService(); + this.mimetypeService = sr.getMimetypeService(); + this.contentService = sr.getContentService(); + this.actionService = sr.getActionService(); + this.versionService = sr.getVersionService(); + if (defaultIgnoreTypes != null) { ignoreTypeQNames = new HashSet<>(defaultIgnoreTypes.size()); @@ -126,12 +163,6 @@ public class NodesImpl implements Nodes public void setServiceRegistry(ServiceRegistry sr) { this.sr = sr; - - this.namespaceService = sr.getNamespaceService(); - this.fileFolderService = sr.getFileFolderService(); - this.nodeService = sr.getNodeService(); - this.permissionService = sr.getPermissionService(); - this.dictionaryService = sr.getDictionaryService(); } public void setRepositoryHelper(Repository repositoryHelper) @@ -785,15 +816,15 @@ public class NodesImpl implements Nodes } // TODO should we able to specify content properties (eg. mimeType ... or use extension for now, or encoding) - public Node createNode(String parentFolderNodeId, Node nodeInfo, Parameters parameters) - { + public Node createNode(String parentFolderNodeId, Node nodeInfo, Parameters parameters) + { // check that requested parent node exists and it's type is a (sub-)type of folder final NodeRef parentNodeRef = validateOrLookupNode(parentFolderNodeId, null); - if (! nodeMatches(parentNodeRef, Collections.singleton(ContentModel.TYPE_FOLDER), null)) - { - throw new InvalidArgumentException("NodeId of folder is expected: "+parentNodeRef); - } + if (! nodeMatches(parentNodeRef, Collections.singleton(ContentModel.TYPE_FOLDER), null)) + { + throw new InvalidArgumentException("NodeId of folder is expected: "+parentNodeRef); + } String nodeName = nodeInfo.getName(); if ((nodeName == null) || nodeName.isEmpty()) @@ -842,7 +873,7 @@ public class NodesImpl implements Nodes } return getFolderOrDocument(nodeRef.getId(), parameters); - } + } public Node updateNode(String nodeId, Node nodeInfo, Parameters parameters) { @@ -926,6 +957,259 @@ public class NodesImpl implements Nodes return; } + @Override + public Node upload(String parentFolderNodeId, FormData formData, Parameters parameters) + { + if (formData == null || !formData.getIsMultiPart()) + { + throw new InvalidArgumentException("The request content-type is not multipart"); + } + + final NodeRef parentNodeRef = validateOrLookupNode(parentFolderNodeId, null); + if (Type.DOCUMENT == getType(parentNodeRef)) + { + throw new InvalidArgumentException(parentFolderNodeId + " is not a folder."); + } + + String fileName = null; + Content content = null; + boolean overwrite = false; // If a fileName clashes for a versionable file + + for (FormData.FormField field : formData.getFields()) + { + switch (field.getName().toLowerCase()) + { + case "filename": + fileName = getStringOrNull(field.getValue()); + break; + + case "filedata": + if (field.getIsFile()) + { + fileName = fileName != null ? fileName : field.getFilename(); + content = field.getContent(); + } + break; + + case "overwrite": + overwrite = Boolean.valueOf(field.getValue()); + break; + } + } + + try + { + // MNT-7213 When alf_data runs out of disk space, Share uploads + // result in a success message, but the files do not appear. + if (formData.getFields().length == 0) + { + throw new ConstraintViolatedException(" No disk space available"); + } + + // Ensure mandatory file attributes have been located. Need either + // destination, or site + container or updateNodeRef + if ((fileName == null || content == null)) + { + throw new InvalidArgumentException("Required parameters are missing"); + } + /* + * Existing file handling + */ + NodeRef existingFile = nodeService.getChildByName(parentNodeRef, ContentModel.ASSOC_CONTAINS, fileName); + if (existingFile != null) + { + // File already exists, decide what to do + if (overwrite && nodeService.hasAspect(existingFile, ContentModel.ASPECT_VERSIONABLE)) + { + // Upload component was configured to overwrite files if name clashes + write(existingFile, content, fileName, false, true); + + // Extract the metadata (The overwrite policy controls + // which if any parts of the document's properties are updated from this) + extractMetadata(existingFile); + + // Do not clean formData temp files to + // allow for retries. Temp files will be deleted later + // when GC call DiskFileItem#finalize() method or by temp file cleaner. + return createUploadResponse(parentNodeRef, existingFile); + } + else + { + throw new ConstraintViolatedException(fileName + " already exists."); + } + } + + // Create a new file. + return createNewFile(parentNodeRef, fileName, content); + + // Do not clean formData temp files to allow for retries. + // Temp files will be deleted later when GC call DiskFileItem#finalize() method or by temp file cleaner. + } + catch (ApiException apiEx) + { + // As this is an public API fwk exception, there is no need to convert it, so just throw it. + throw apiEx; + } + catch (AccessDeniedException ade) + { + throw new PermissionDeniedException(); + } + catch (ContentQuotaException cqe) + { + throw new RequestEntityTooLargeException(); + } + catch (ContentLimitViolationException clv) + { + throw new ConstraintViolatedException(); + } + catch (Exception ex) + { + /* + * NOTE: Do not clean formData temp files to allow for retries. It's + * possible for a temp file to remain if max retry attempts are + * made, but this is rare, so leave to usual temp file cleanup. + */ + + throw new ApiException("Unexpected error occurred during upload of new content.", ex); + } + } + + /** + * Helper to create a new node and writes its content to the repository. + */ + private Node createNewFile(NodeRef parentNodeRef, String fileName, Content content) + { + FileInfo fileInfo = fileFolderService.create(parentNodeRef, fileName, ContentModel.TYPE_CONTENT); + NodeRef newFile = fileInfo.getNodeRef(); + + // Write content + write(newFile, content, fileName, false, true); + + // Ensure the file is versionable (autoVersion = true, autoVersionProps = false) + ensureVersioningEnabled(newFile, true, false); + + // Extract the metadata + extractMetadata(newFile); + + // Create the response + return createUploadResponse(parentNodeRef, newFile); + } + + private Node createUploadResponse(NodeRef parentNodeRef, NodeRef newFileNodeRef) + { + return getFolderOrDocument(newFileNodeRef, parentNodeRef, ContentModel.TYPE_CONTENT, Collections.emptyList(), true, null); + } + + private String getStringOrNull(String value) + { + if (StringUtils.isNotEmpty(value)) + { + return value.equalsIgnoreCase("null") ? null : value; + } + return null; + } + + /** + * Writes the content to the repository. + * + * @param nodeRef the reference to the node having a content property + * @param content the content + * @param fileName the uploaded file name + * @param applyMimeType If true, apply the mimeType from the Content object, + * else leave the original mimeType + * @param guessEncoding If true, guess the encoding from the underlying + * input stream, else use encoding set in the Content object as supplied + */ + protected void write(NodeRef nodeRef, Content content, String fileName, boolean applyMimeType, boolean guessEncoding) + { + ContentWriter writer = contentService.getWriter(nodeRef, ContentModel.PROP_CONTENT, true); + InputStream is; + String mimeType = content.getMimetype(); + if (!applyMimeType) + { + ContentData existingContentData = (ContentData) nodeService.getProperty(nodeRef, ContentModel.PROP_CONTENT); + if (existingContentData != null) + { + mimeType = existingContentData.getMimetype(); + } + else + { + mimeType = mimetypeService.guessMimetype(fileName); + } + } + if (guessEncoding) + { + is = new BufferedInputStream(content.getInputStream()); + is.mark(1024); + + writer.setEncoding(guessEncoding(is, mimeType)); + try + { + is.reset(); + } + catch (IOException e) + { + logger.error("Failed to reset input stream", e); + } + } + else + { + writer.setEncoding(content.getEncoding()); + is = content.getInputStream(); + } + writer.setMimetype(mimeType); + writer.putContent(is); + } + + /** + * Ensures the given node has the {@code cm:versionable} aspect applied to it, and + * that it has the initial version in the version store. + * + * @param nodeRef the reference to the node to be checked + * @param autoVersion If the {@code cm:versionable} aspect is applied, should auto + * versioning be requested? + * @param autoVersionProps If the {@code cm:versionable} aspect is applied, should + * auto versioning of properties be requested? + */ + protected void ensureVersioningEnabled(NodeRef nodeRef, boolean autoVersion, boolean autoVersionProps) + { + Map props = new HashMap<>(2); + props.put(ContentModel.PROP_AUTO_VERSION, autoVersion); + props.put(ContentModel.PROP_AUTO_VERSION_PROPS, autoVersionProps); + + versionService.ensureVersioningEnabled(nodeRef, props); + } + + /** + * Guesses the character encoding of the given inputStream. + */ + protected String guessEncoding(InputStream in, String mimeType) + { + String encoding = "UTF-8"; + + if (in != null) + { + Charset charset = mimetypeService.getContentCharsetFinder().getCharset(in, mimeType); + encoding = charset.name(); + } + + return encoding; + } + + /** + * Extracts the given node metadata asynchronously. + */ + private void extractMetadata(NodeRef nodeRef) + { + final String actionName = "extract-metadata"; + ActionDefinition actionDef = actionService.getActionDefinition(actionName); + if (actionDef != null) + { + Action action = actionService.createAction(actionName); + actionService.executeAction(action, nodeRef); + } + } + /** * Helper to create a QName from either a fully qualified or short-name QName string * diff --git a/source/java/org/alfresco/rest/api/nodes/NodeChildrenRelation.java b/source/java/org/alfresco/rest/api/nodes/NodeChildrenRelation.java index 723f7104e2..07e2a9834d 100644 --- a/source/java/org/alfresco/rest/api/nodes/NodeChildrenRelation.java +++ b/source/java/org/alfresco/rest/api/nodes/NodeChildrenRelation.java @@ -19,15 +19,17 @@ package org.alfresco.rest.api.nodes; import org.alfresco.rest.api.Nodes; -import org.alfresco.rest.api.model.Folder; import org.alfresco.rest.api.model.Node; import org.alfresco.rest.framework.WebApiDescription; +import org.alfresco.rest.framework.WebApiParam; import org.alfresco.rest.framework.resource.RelationshipResource; +import org.alfresco.rest.framework.resource.actions.interfaces.MultiPartRelationshipResourceAction; import org.alfresco.rest.framework.resource.actions.interfaces.RelationshipResourceAction; import org.alfresco.rest.framework.resource.parameters.CollectionWithPagingInfo; import org.alfresco.rest.framework.resource.parameters.Parameters; import org.alfresco.util.ParameterCheck; import org.springframework.beans.factory.InitializingBean; +import org.springframework.extensions.webscripts.servlet.FormData; import java.util.ArrayList; import java.util.List; @@ -36,18 +38,20 @@ import java.util.List; * TODO ... work-in-progress * * @author janv + * @author Jamal Kaabi-Mofrad */ @RelationshipResource(name = "children", entityResource = NodesEntityResource.class, title = "Folder children") -public class NodeChildrenRelation implements RelationshipResourceAction.Read, RelationshipResourceAction.Create, InitializingBean +public class NodeChildrenRelation implements RelationshipResourceAction.Read, RelationshipResourceAction.Create, + MultiPartRelationshipResourceAction.Create, InitializingBean { - private Nodes nodes; + private Nodes nodes; public void setNodes(Nodes nodes) { this.nodes = nodes; } - @Override + @Override public void afterPropertiesSet() { ParameterCheck.mandatory("nodes", this.nodes); @@ -104,4 +108,13 @@ public class NodeChildrenRelation implements RelationshipResourceAction.Read. + */ + +package org.alfresco.rest.framework.core.exceptions; + +/** + * @author Jamal Kaabi-Mofrad + */ +public class RequestEntityTooLargeException extends ApiException +{ + private static final long serialVersionUID = 3196212354672333823L; + + public static String DEFAULT_MESSAGE_ID = "framework.exception.RequestEntityTooLarge"; + + public RequestEntityTooLargeException() + { + super(DEFAULT_MESSAGE_ID); + } + + public RequestEntityTooLargeException(String msgId) + { + super(msgId); + } +} diff --git a/source/test-java/org/alfresco/rest/api/tests/NodeApiTest.java b/source/test-java/org/alfresco/rest/api/tests/NodeApiTest.java index 6d16885058..1c1efd7249 100644 --- a/source/test-java/org/alfresco/rest/api/tests/NodeApiTest.java +++ b/source/test-java/org/alfresco/rest/api/tests/NodeApiTest.java @@ -19,18 +19,22 @@ package org.alfresco.rest.api.tests; +import static org.alfresco.rest.api.tests.util.RestApiUtil.parsePaging; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import org.alfresco.model.ContentModel; import org.alfresco.model.ForumModel; import org.alfresco.repo.content.MimetypeMap; import org.alfresco.repo.model.Repository; import org.alfresco.repo.security.authentication.AuthenticationUtil; import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback; +import org.alfresco.rest.api.model.ContentInfo; import org.alfresco.rest.api.model.Document; +import org.alfresco.rest.api.model.Folder; import org.alfresco.rest.api.model.Node; import org.alfresco.rest.api.model.PathInfo; import org.alfresco.rest.api.model.PathInfo.ElementInfo; @@ -40,10 +44,14 @@ import org.alfresco.rest.api.tests.RepoService.TestNetwork; import org.alfresco.rest.api.tests.RepoService.TestPerson; import org.alfresco.rest.api.tests.RepoService.TestSite; import org.alfresco.rest.api.tests.client.HttpResponse; +import org.alfresco.rest.api.tests.client.PublicApiClient; import org.alfresco.rest.api.tests.client.PublicApiClient.ExpectedPaging; import org.alfresco.rest.api.tests.client.PublicApiClient.Paging; import org.alfresco.rest.api.tests.client.data.SiteRole; import org.alfresco.rest.api.tests.util.JacksonUtil; +import org.alfresco.rest.api.tests.util.MultiPartBuilder; +import org.alfresco.rest.api.tests.util.MultiPartBuilder.FileData; +import org.alfresco.rest.api.tests.util.MultiPartBuilder.MultiPartRequest; import org.alfresco.rest.api.tests.util.RestApiUtil; import org.alfresco.rest.framework.jacksonextensions.JacksonHelper; import org.alfresco.service.cmr.repository.NodeRef; @@ -54,8 +62,12 @@ import org.alfresco.service.cmr.site.SiteVisibility; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.springframework.util.ResourceUtils; +import java.io.File; +import java.io.FileNotFoundException; import java.io.Serializable; +import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -67,14 +79,16 @@ import java.util.Set; /** * API tests for: *
    - *
  • {@literal host:port/alfresco/api/{networkId}/public/alfresco/versions/1/nodes/{nodeId}}
  • - *
  • {@literal host:port/alfresco/api/{networkId}/public/alfresco/versions/1/nodes/{nodeId}/children}
  • + *
  • {@literal :/alfresco/api//public/alfresco/versions/1/nodes/}
  • + *
  • {@literal :/alfresco/api//public/alfresco/versions/1/nodes//children}
  • *
* * @author Jamal Kaabi-Mofrad */ public class NodeApiTest extends AbstractBaseApiTest { + private static final String RESOURCE_PREFIX = "publicapi/upload/"; + /** * User one from network one */ @@ -145,6 +159,11 @@ public class NodeApiTest extends AbstractBaseApiTest AuthenticationUtil.clearCurrentSecurityContext(); } + /** + * Tests get document library children. + *

GET:

+ * {@literal :/alfresco/api//public/alfresco/versions/1/nodes//children} + */ @Test public void testListDocLibChildren() throws Exception { @@ -246,6 +265,11 @@ public class NodeApiTest extends AbstractBaseApiTest getAll(getChildrenUrl(docLibNodeRef), userTwoN1.getId(), paging, 403); } + /** + * Tests get user's home children. + *

GET:

+ * {@literal :/alfresco/api/-default-/public/alfresco/versions/1/nodes//children} + */ @Test public void testListMyFilesChildren() throws Exception { @@ -317,6 +341,11 @@ public class NodeApiTest extends AbstractBaseApiTest assertEquals("doclib:1444660852296", ((List) entry.getValue()).get(0)); } + /** + * Tests get node with path information. + *

GET:

+ * {@literal :/alfresco/api//public/alfresco/versions/1/nodes/?select=path} + */ @Test public void testGetPathElements_DocLib() throws Exception { @@ -385,6 +414,11 @@ public class NodeApiTest extends AbstractBaseApiTest assertEquals(folderC, pathElements.get(0).getName()); } + /** + * Tests get node with path information. + *

GET:

+ * {@literal :/alfresco/api/-default-/public/alfresco/versions/1/nodes/?select=path} + */ @Test public void testGetPathElements_MyFiles() throws Exception { @@ -434,6 +468,14 @@ public class NodeApiTest extends AbstractBaseApiTest assertNotNull(pathElements.get(4).getId()); } + /** + * Tests well-known aliases. + *

GET:

+ *
    + *
  • {@literal :/alfresco/api/-default-/public/alfresco/versions/1/nodes/-root-}
  • + *
  • {@literal :/alfresco/api/-default-/public/alfresco/versions/1/nodes/-my-}
  • + *
+ */ @Test public void testGetNodeWithKnownAlias() throws Exception { @@ -472,9 +514,170 @@ public class NodeApiTest extends AbstractBaseApiTest getSingle(NodesEntityResource.class, user1, userNodeAlias, null, 404); // Not found } - private String getChildrenUrl(NodeRef nodeRef) + /** + * Tests Multipart upload to user's home (a.k.a My Files). + *

POST:

+ * {@literal :/alfresco/api/-default-/public/alfresco/versions/1/nodes//children} + */ + @Test + public void testUploadToMyFiles() throws Exception { - return "nodes/" + nodeRef.getId() + "/children"; + final String userNodeAlias = "-my-"; + final String fileName = "quick.pdf"; + final File file = getResourceFile(fileName); + + Paging paging = getPaging(0, Integer.MAX_VALUE); + HttpResponse response = getAll(getChildrenUrl(userNodeAlias), user1, paging, 200); + PublicApiClient.ExpectedPaging pagingResult = parsePaging(response.getJsonResponse()); + assertNotNull(paging); + final int numOfNodes = pagingResult.getCount().intValue(); + + MultiPartBuilder multiPartBuilder = MultiPartBuilder.create() + .setFileData(new FileData(fileName, file, MimetypeMap.MIMETYPE_PDF)); + MultiPartRequest reqBody = multiPartBuilder.build(); + + // Try to upload + response = post(getChildrenUrl(userNodeAlias), user1, new String(reqBody.getBody()), null, reqBody.getContentType(), 201); + Document document = jacksonUtil.parseEntry(response.getJsonResponse(), Document.class); + // Check the upload response + assertEquals(fileName, document.getName()); + ContentInfo contentInfo = document.getContent(); + assertNotNull(contentInfo); + assertEquals(MimetypeMap.MIMETYPE_PDF, contentInfo.getMimeType()); + + // Retrieve the uploaded file + response = getSingle(NodesEntityResource.class, user1, document.getNodeRef().getId(), null, 200); + document = jacksonUtil.parseEntry(response.getJsonResponse(), Document.class); + assertEquals(fileName, document.getName()); + contentInfo = document.getContent(); + assertNotNull(contentInfo); + assertEquals(MimetypeMap.MIMETYPE_PDF, contentInfo.getMimeType()); + + // Check 'get children' is confirming the upload + response = getAll(getChildrenUrl(userNodeAlias), user1, paging, 200); + pagingResult = parsePaging(response.getJsonResponse()); + assertNotNull(paging); + assertEquals(numOfNodes + 1, pagingResult.getCount().intValue()); + + // Upload the same file again to check the name conflicts handling + post(getChildrenUrl(userNodeAlias), user1, new String(reqBody.getBody()), null, reqBody.getContentType(), 409); + + response = getAll(getChildrenUrl(userNodeAlias), user1, paging, 200); + pagingResult = parsePaging(response.getJsonResponse()); + assertNotNull(paging); + assertEquals("Duplicate file name. The file shouldn't have been uploaded.", numOfNodes + 1, pagingResult.getCount().intValue()); + + // User2 tries to upload a new file into the user1's home folder. + response = getSingle(NodesEntityResource.class, user1, userNodeAlias, null, 200); + Folder user1Home = jacksonUtil.parseEntry(response.getJsonResponse(), Folder.class); + final String fileName2 = "quick-2.txt"; + final File file2 = getResourceFile(fileName2); + reqBody = MultiPartBuilder.create() + .setFileData(new FileData(fileName2, file2, MimetypeMap.MIMETYPE_TEXT_PLAIN)) + .build(); + post(getChildrenUrl(user1Home.getNodeRef()), user2, new String(reqBody.getBody()), null, reqBody.getContentType(), 403); + + response = getAll(getChildrenUrl(userNodeAlias), user1, paging, 200); + pagingResult = parsePaging(response.getJsonResponse()); + assertNotNull(paging); + assertEquals("Access Denied. The file shouldn't have been uploaded.", numOfNodes + 1, pagingResult.getCount().intValue()); + + // User1 tries to upload a file into a document rather than a folder! + post(getChildrenUrl(document.getNodeRef()), user1, new String(reqBody.getBody()), null, reqBody.getContentType(), 400); + + // Try to upload a file without defining the required formData + reqBody = MultiPartBuilder.create().build(); + post(getChildrenUrl(userNodeAlias), user1, new String(reqBody.getBody()), null, reqBody.getContentType(), 400); + } + + /** + * Tests Multipart upload to a Site. + *

POST:

+ * {@literal :/alfresco/api//public/alfresco/versions/1/nodes//children} + */ + @Test + public void testUploadToSite() throws Exception + { + final String fileName = "quick-1.txt"; + final File file = getResourceFile(fileName); + + AuthenticationUtil.setFullyAuthenticatedUser(userOneN1.getId()); + String folderA = "folder" + System.currentTimeMillis() + "_A"; + NodeRef folderA_Ref = repoService.addToDocumentLibrary(userOneN1Site, folderA, ContentModel.TYPE_FOLDER); + + Paging paging = getPaging(0, Integer.MAX_VALUE); + HttpResponse response = getAll(getChildrenUrl(folderA_Ref), userOneN1.getId(), paging, 200); + PublicApiClient.ExpectedPaging pagingResult = parsePaging(response.getJsonResponse()); + assertNotNull(paging); + final int numOfNodes = pagingResult.getCount().intValue(); + + MultiPartBuilder multiPartBuilder = MultiPartBuilder.create() + .setFileData(new FileData(fileName, file, MimetypeMap.MIMETYPE_TEXT_PLAIN)); + MultiPartRequest reqBody = multiPartBuilder.build(); + // Try to upload + response = post(getChildrenUrl(folderA_Ref), userOneN1.getId(), new String(reqBody.getBody()), null, reqBody.getContentType(), 201); + Document document = jacksonUtil.parseEntry(response.getJsonResponse(), Document.class); + // Check the upload response + assertEquals(fileName, document.getName()); + ContentInfo contentInfo = document.getContent(); + assertNotNull(contentInfo); + assertEquals(MimetypeMap.MIMETYPE_TEXT_PLAIN, contentInfo.getMimeType()); + + // Retrieve the uploaded file + response = getSingle(NodesEntityResource.class, userOneN1.getId(), document.getNodeRef().getId(), null, 200); + document = jacksonUtil.parseEntry(response.getJsonResponse(), Document.class); + assertEquals(fileName, document.getName()); + contentInfo = document.getContent(); + assertNotNull(contentInfo); + assertEquals(MimetypeMap.MIMETYPE_TEXT_PLAIN, contentInfo.getMimeType()); + + // Check 'get children' is confirming the upload + response = getAll(getChildrenUrl(folderA_Ref), userOneN1.getId(), paging, 200); + pagingResult = parsePaging(response.getJsonResponse()); + assertNotNull(paging); + assertEquals(numOfNodes + 1, pagingResult.getCount().intValue()); + + // Upload the same file again to check the name conflicts handling + post(getChildrenUrl(folderA_Ref), userOneN1.getId(), new String(reqBody.getBody()), null, reqBody.getContentType(), 409); + + // Set overwrite=true and upload the same file again + reqBody = MultiPartBuilder.copy(multiPartBuilder) + .setOverwrite(true) + .build(); + post(getChildrenUrl(folderA_Ref), userOneN1.getId(), new String(reqBody.getBody()), null, reqBody.getContentType(), 201); + + response = getAll(getChildrenUrl(folderA_Ref), userOneN1.getId(), paging, 200); + pagingResult = parsePaging(response.getJsonResponse()); + assertNotNull(paging); + assertEquals(numOfNodes + 1, pagingResult.getCount().intValue()); + + final String fileName2 = "quick-2.txt"; + final File file2 = getResourceFile(fileName2); + reqBody = MultiPartBuilder.create() + .setFileData(new FileData(fileName2, file2, MimetypeMap.MIMETYPE_TEXT_PLAIN)) + .build(); + // userTwoN1 tries to upload a new file into the folderA of userOneN1 + post(getChildrenUrl(folderA_Ref), userTwoN1.getId(), new String(reqBody.getBody()), null, reqBody.getContentType(), 403); + } + + private String getChildrenUrl(NodeRef parentNodeRef) + { + return getChildrenUrl(parentNodeRef.getId()); + } + + private String getChildrenUrl(String parentId) + { + return "nodes/" + parentId + "/children"; + } + + private File getResourceFile(String fileName) throws FileNotFoundException + { + URL url = NodeApiTest.class.getClassLoader().getResource(RESOURCE_PREFIX + fileName); + if (url == null) + { + fail("Cannot get the resource: " + fileName); + } + return ResourceUtils.getFile(url); } @Override diff --git a/source/test-java/org/alfresco/rest/api/tests/util/MultiPartBuilder.java b/source/test-java/org/alfresco/rest/api/tests/util/MultiPartBuilder.java index 80c1479dcc..16b305fda5 100644 --- a/source/test-java/org/alfresco/rest/api/tests/util/MultiPartBuilder.java +++ b/source/test-java/org/alfresco/rest/api/tests/util/MultiPartBuilder.java @@ -50,7 +50,7 @@ public class MultiPartBuilder private String contentTypeQNameStr; private List aspects; private boolean majorVersion; - private boolean overwrite = true; // If a fileName clashes for a versionable file + private boolean overwrite = false; // If a fileName clashes for a versionable file private MultiPartBuilder() { @@ -225,11 +225,13 @@ public class MultiPartBuilder public MultiPartRequest build() throws IOException { - assertNotNull(fileData); List parts = new ArrayList<>(); - parts.add(new FilePart("filedata", fileData.getFileName(), fileData.getFile(), fileData.getMimetype(), null)); - addPartIfNotNull(parts, "filename", fileData.getFileName()); + if (fileData != null) + { + parts.add(new FilePart("filedata", fileData.getFileName(), fileData.getFile(), fileData.getMimetype(), null)); + addPartIfNotNull(parts, "filename", fileData.getFileName()); + } addPartIfNotNull(parts, "siteid", siteId); addPartIfNotNull(parts, "containerid", containerId); addPartIfNotNull(parts, "destination", destination); diff --git a/source/test-resources/publicapi/upload/quick-1.txt b/source/test-resources/publicapi/upload/quick-1.txt new file mode 100644 index 0000000000..ff3bb63948 --- /dev/null +++ b/source/test-resources/publicapi/upload/quick-1.txt @@ -0,0 +1 @@ +The quick brown fox jumps over the lazy dog \ No newline at end of file diff --git a/source/test-resources/publicapi/upload/quick-2.txt b/source/test-resources/publicapi/upload/quick-2.txt new file mode 100644 index 0000000000..ca103eec21 --- /dev/null +++ b/source/test-resources/publicapi/upload/quick-2.txt @@ -0,0 +1,2 @@ +Gym class featuring a brown fox and lazy dog +The quick brown fox jumps over the lazy dog \ No newline at end of file diff --git a/source/test-resources/publicapi/upload/quick.pdf b/source/test-resources/publicapi/upload/quick.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a1779afd8b4d1b7d75456c61900c5cead5444abb GIT binary patch literal 23697 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≺ND&( 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 Pz?x-07<5&PtcCX{dE{yb literal 0 HcmV?d00001