├── .gitignore ├── .travis.yml ├── DependencyInjection ├── CompilerPass │ └── ProcessorCompilerPass.php ├── Configuration.php ├── Factory │ └── StorageFactory.php └── SRIORestUploadExtension.php ├── Entity └── ResumableUploadSession.php ├── Exception ├── FileExistsException.php ├── FileNotFoundException.php ├── InternalUploadProcessorException.php ├── UploadException.php └── UploadProcessorException.php ├── Model └── UploadableFileInterface.php ├── Processor ├── AbstractUploadProcessor.php ├── FormDataUploadProcessor.php ├── MultipartUploadProcessor.php ├── ProcessorInterface.php ├── ResumableUploadProcessor.php └── SimpleUploadProcessor.php ├── README.md ├── Request ├── RequestContentHandler.php └── RequestContentHandlerInterface.php ├── Resources ├── config │ ├── doctrine │ │ └── ResumableUploadSession.orm.xml │ ├── handlers.xml │ ├── processors.xml │ ├── storage.xml │ └── strategy.xml └── doc │ ├── advanced.md │ ├── installation.md │ ├── reference.md │ ├── upload-ways.md │ └── usage.md ├── SRIORestUploadBundle.php ├── Storage ├── FileAdapterInterface.php ├── FileStorage.php ├── FilesystemAdapterInterface.php ├── FlysystemFileAdapter.php ├── FlysystemFilesystemAdapter.php ├── GaufretteFileAdapter.php ├── GaufretteFilesystemAdapter.php └── UploadedFile.php ├── Strategy ├── DefaultNamingStrategy.php ├── DefaultStorageStrategy.php ├── NamingStrategy.php └── StorageStrategy.php ├── Tests ├── Fixtures │ ├── App │ │ ├── app │ │ │ ├── AppKernel.php │ │ │ ├── config │ │ │ │ ├── config.yml │ │ │ │ ├── config_flysystem.yml │ │ │ │ ├── config_gaufrette.yml │ │ │ │ ├── config_test.yml │ │ │ │ ├── parameters.yml │ │ │ │ ├── parameters.yml.dist │ │ │ │ └── routing.yml │ │ │ └── console │ │ └── web │ │ │ ├── .gitignore │ │ │ └── uploads │ │ │ └── .gitkeep │ ├── Controller │ │ └── UploadController.php │ ├── Entity │ │ ├── Media.php │ │ └── ResumableUploadSession.php │ ├── Form │ │ └── Type │ │ │ └── MediaFormType.php │ └── Resources │ │ ├── apple.gif │ │ └── lorem.txt ├── Processor │ ├── AbstractProcessorTestCase.php │ ├── MultipartUploadProcessorTest.php │ └── ResumableUploadProcessorTest.php ├── Request │ └── RequestContentHandlerTest.php ├── Upload │ ├── AbstractUploadTestCase.php │ ├── MultipartUploadTest.php │ ├── ResumableUploadTest.php │ └── SimpleUploadTest.php └── bootstrap.php ├── Upload ├── StorageHandler.php ├── UploadContext.php ├── UploadHandler.php └── UploadResult.php ├── Voter └── StorageVoter.php ├── composer.json ├── composer.lock ├── phpunit.xml.dist └── test.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | *~ 4 | 5 | /vendor 6 | /cov 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | env: 3 | - DB=mysql 4 | 5 | php: 6 | - 5.5 7 | - 5.4 8 | 9 | before_script: 10 | - composer install --dev --no-interaction 11 | - sh -c "if [ '$DB' = 'mysql' ]; then mysql -e 'create database IF NOT EXISTS restuploadbundle;'; fi" 12 | - php Tests/Fixtures/App/app/console doctrine:schema:update --force --env=test 13 | 14 | script: 15 | - sh -c "export TEST_FILESYSTEM="Gaufrette"; phpunit" 16 | - sh -c "export TEST_FILESYSTEM="Flysystem"; phpunit" 17 | 18 | notifications: 19 | email: 20 | - travis+restuploadbundle@sroze.io 21 | - samuel.roze@gmail.com -------------------------------------------------------------------------------- /DependencyInjection/CompilerPass/ProcessorCompilerPass.php: -------------------------------------------------------------------------------- 1 | hasDefinition('srio_rest_upload.upload_handler')) { 14 | return; 15 | } 16 | 17 | $uploadHandlerDefinition = $container->getDefinition('srio_rest_upload.upload_handler'); 18 | $processorDefinitions = $container->findTaggedServiceIds('rest_upload.processor'); 19 | 20 | foreach ($processorDefinitions as $id => $tagAttributes) { 21 | foreach ($tagAttributes as $attributes) { 22 | if (!array_key_exists('uploadType', $attributes)) { 23 | throw new \LogicException('A "rest_upload.processor" tag must have "uploadType" attribute'); 24 | } 25 | 26 | $uploadHandlerDefinition->addMethodCall('addProcessor', array($attributes['uploadType'], new Reference($id))); 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | root('srio_rest_upload'); 22 | 23 | $rootNode 24 | ->children() 25 | ->arrayNode('storages') 26 | ->useAttributeAsKey('name') 27 | ->prototype('array') 28 | ->children() 29 | ->enumNode('type') 30 | ->values(array('gaufrette', 'flysystem')) 31 | ->defaultValue('gaufrette') 32 | ->end() 33 | ->scalarNode('filesystem')->isRequired()->end() 34 | ->scalarNode('naming_strategy') 35 | ->defaultValue('srio_rest_upload.naming.default_strategy') 36 | ->end() 37 | ->scalarNode('storage_strategy') 38 | ->defaultValue('srio_rest_upload.storage.default_strategy') 39 | ->end() 40 | ->end() 41 | ->end() 42 | ->end() 43 | ->scalarNode('default_storage')->defaultNull()->end() 44 | ->scalarNode('storage_voter') 45 | ->cannotBeEmpty() 46 | ->defaultValue('srio_rest_upload.storage_voter.default') 47 | ->end() 48 | ->scalarNode('resumable_entity_class')->defaultNull()->end() 49 | ->scalarNode('upload_type_parameter') 50 | ->defaultValue('uploadType') 51 | ->end() 52 | ->end(); 53 | 54 | return $treeBuilder; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /DependencyInjection/Factory/StorageFactory.php: -------------------------------------------------------------------------------- 1 | setPublic(false); 22 | $adapterDefinition->replaceArgument(0, new Reference($config['filesystem'])); 23 | 24 | $container->setDefinition($adapterId, $adapterDefinition); 25 | } elseif ($config['type'] === 'flysystem') { 26 | $adapterDefinition = new DefinitionDecorator('srio_rest_upload.storage.flysystem_adapter'); 27 | $adapterDefinition->setPublic(false); 28 | $adapterDefinition->replaceArgument(0, new Reference($config['filesystem'])); 29 | 30 | $container->setDefinition($adapterId, $adapterDefinition); 31 | } 32 | 33 | $container 34 | ->setDefinition($id, new Definition('SRIO\RestUploadBundle\Storage\FileStorage')) 35 | ->addArgument($config['name']) 36 | ->addArgument(new Reference($adapterId)) 37 | ->addArgument(new Reference($config['storage_strategy'])) 38 | ->addArgument(new Reference($config['naming_strategy'])) 39 | ; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /DependencyInjection/SRIORestUploadExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 27 | 28 | $container->setParameter('srio_rest_upload.upload_type_parameter', $config['upload_type_parameter']); 29 | $container->setParameter('srio_rest_upload.resumable_entity_class', $config['resumable_entity_class']); 30 | $container->setParameter('srio_rest_upload.default_storage', $config['default_storage']); 31 | 32 | $this->createStorageVoter($container, $config['storage_voter']); 33 | 34 | $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 35 | $loader->load('processors.xml'); 36 | $loader->load('handlers.xml'); 37 | $loader->load('strategy.xml'); 38 | $loader->load('storage.xml'); 39 | 40 | $this->createStorageServices($container, $config['storages']); 41 | } 42 | 43 | /** 44 | * Create storage services. 45 | * 46 | * @param ContainerBuilder $container 47 | * @param array $storageDefinitions 48 | */ 49 | private function createStorageServices(ContainerBuilder $container, array $storageDefinitions) 50 | { 51 | $voterDefinition = $container->getDefinition('srio_rest_upload.storage_voter'); 52 | $factory = new StorageFactory(); 53 | 54 | foreach ($storageDefinitions as $name => $storage) { 55 | $id = $this->createStorage($factory, $container, $name, $storage); 56 | $voterDefinition->addMethodCall('addStorage', array(new Reference($id))); 57 | } 58 | } 59 | 60 | /** 61 | * Create a single storage service. 62 | * 63 | * @param StorageFactory $factory 64 | * @param ContainerBuilder $containerBuilder 65 | * @param $name 66 | * @param array $config 67 | * 68 | * @return string 69 | */ 70 | private function createStorage(StorageFactory $factory, ContainerBuilder $containerBuilder, $name, array $config) 71 | { 72 | $id = sprintf('srio_rest_upload.storage.%s', $name); 73 | 74 | $config['name'] = $name; 75 | $factory->create($containerBuilder, $id, $config); 76 | 77 | return $id; 78 | } 79 | 80 | /** 81 | * Create the storage voter. 82 | * 83 | * @param ContainerBuilder $builder 84 | * @param $service 85 | */ 86 | private function createStorageVoter(ContainerBuilder $builder, $service) 87 | { 88 | $definition = new DefinitionDecorator($service); 89 | $builder->setDefinition('srio_rest_upload.storage_voter', $definition); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Entity/ResumableUploadSession.php: -------------------------------------------------------------------------------- 1 | filePath = $filePath; 59 | } 60 | 61 | /** 62 | * @return string 63 | */ 64 | public function getFilePath() 65 | { 66 | return $this->filePath; 67 | } 68 | 69 | /** 70 | * @param string $sessionId 71 | */ 72 | public function setSessionId($sessionId) 73 | { 74 | $this->sessionId = $sessionId; 75 | } 76 | 77 | /** 78 | * @return string 79 | */ 80 | public function getSessionId() 81 | { 82 | return $this->sessionId; 83 | } 84 | 85 | /** 86 | * @param string $data 87 | */ 88 | public function setData($data) 89 | { 90 | $this->data = $data; 91 | } 92 | 93 | /** 94 | * @return string 95 | */ 96 | public function getData() 97 | { 98 | return $this->data; 99 | } 100 | 101 | /** 102 | * @param int $contentLength 103 | */ 104 | public function setContentLength($contentLength) 105 | { 106 | $this->contentLength = $contentLength; 107 | } 108 | 109 | /** 110 | * @return int 111 | */ 112 | public function getContentLength() 113 | { 114 | return $this->contentLength; 115 | } 116 | 117 | /** 118 | * @param string $contentType 119 | */ 120 | public function setContentType($contentType) 121 | { 122 | $this->contentType = $contentType; 123 | } 124 | 125 | /** 126 | * @return string 127 | */ 128 | public function getContentType() 129 | { 130 | return $this->contentType; 131 | } 132 | 133 | /** 134 | * @param string $storageName 135 | */ 136 | public function setStorageName($storageName) 137 | { 138 | $this->storageName = $storageName; 139 | } 140 | 141 | /** 142 | * @return string 143 | */ 144 | public function getStorageName() 145 | { 146 | return $this->storageName; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Exception/FileExistsException.php: -------------------------------------------------------------------------------- 1 | storageHandler = $storageHandler; 45 | } 46 | 47 | /** 48 | * Constructor. 49 | * 50 | * @param Request $request 51 | * @param FormInterface $form 52 | * @param array $config 53 | * 54 | * @return bool 55 | */ 56 | public function handleUpload(Request $request, FormInterface $form = null, array $config = array()) 57 | { 58 | $this->form = $form; 59 | $this->config = $config; 60 | 61 | return $this->handleRequest($request); 62 | } 63 | 64 | /** 65 | * Handle an upload request. 66 | * 67 | * This method return a Response object that will be sent back 68 | * to the client or will be caught by controller. 69 | * 70 | * @param Request $request 71 | * 72 | * @return \SRIO\RestUploadBundle\Upload\UploadResult 73 | */ 74 | abstract public function handleRequest(Request $request); 75 | 76 | /** 77 | * Create the form data that the form will be able to handle. 78 | * 79 | * It walk one the form and make an intersection between its keys and 80 | * provided data. 81 | * 82 | * @param array $data 83 | * 84 | * @return array 85 | */ 86 | protected function createFormData(array $data) 87 | { 88 | $keys = $this->getFormKeys($this->form); 89 | 90 | return array_intersect_key($data, $keys); 91 | } 92 | 93 | /** 94 | * Get keys of the form. 95 | * 96 | * @param FormInterface $form 97 | * 98 | * @return array 99 | */ 100 | protected function getFormKeys(FormInterface $form) 101 | { 102 | $keys = array(); 103 | foreach ($form->all() as $child) { 104 | $keys[$child->getName()] = count($child->all()) > 0 ? $this->getFormKeys($child) : null; 105 | } 106 | 107 | return $keys; 108 | } 109 | 110 | /** 111 | * Get a request content handler. 112 | * 113 | * @param Request $request 114 | * 115 | * @return RequestContentHandlerInterface 116 | */ 117 | protected function getRequestContentHandler(Request $request) 118 | { 119 | if ($this->contentHandler === null) { 120 | $this->contentHandler = new RequestContentHandler($request); 121 | } 122 | 123 | return $this->contentHandler; 124 | } 125 | 126 | /** 127 | * Check that needed headers are here. 128 | * 129 | * @param Request $request the request 130 | * @param array $headers the headers to check 131 | * 132 | * @throws \SRIO\RestUploadBundle\Exception\UploadException 133 | */ 134 | protected function checkHeaders(Request $request, array $headers) 135 | { 136 | foreach ($headers as $header) { 137 | $value = $request->headers->get($header, null); 138 | if ($value === null) { 139 | throw new UploadException(sprintf('%s header is needed', $header)); 140 | } elseif (!is_int($value) && empty($value) && $value !== '0') { 141 | throw new UploadException(sprintf('%s header must not be empty', $header)); 142 | } 143 | } 144 | } 145 | 146 | /** 147 | * Set the uploaded file on the form data. 148 | * 149 | * @param UploadedFile $file 150 | * 151 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 152 | * 153 | * @deprecated 154 | */ 155 | protected function setUploadedFile(UploadedFile $file) 156 | { 157 | $data = $this->form->getData(); 158 | if ($data instanceof UploadableFileInterface) { 159 | $data->setFile($file); 160 | } else { 161 | throw new UploadProcessorException(sprintf( 162 | 'Unable to set file, %s do not implements %s', 163 | get_class($data), 164 | 'SRIO\RestUploadBundle\Model\UploadableFileInterface' 165 | )); 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Processor/FormDataUploadProcessor.php: -------------------------------------------------------------------------------- 1 | 'file', 23 | self::KEY_FIELD_FORM => 'form', 24 | ), $config); 25 | 26 | return parent::handleUpload($request, $form, $config); 27 | } 28 | 29 | /** 30 | * @param Request $request 31 | * 32 | * @return \SRIO\RestUploadBundle\Upload\UploadResult 33 | * 34 | * @throws \Exception|\SRIO\RestUploadBundle\Exception\UploadException 35 | */ 36 | public function handleRequest(Request $request) 37 | { 38 | // Check that needed headers exists 39 | $this->checkHeaders($request, array('Content-Length', 'Content-Type')); 40 | if (!$request->files->has($this->config[self::KEY_FIELD_FILE])) { 41 | throw new UploadException(sprintf('%s file not found', $this->config[self::KEY_FIELD_FILE])); 42 | } 43 | 44 | $response = new UploadResult(); 45 | $response->setRequest($request); 46 | $response->setConfig($this->config); 47 | 48 | if ($this->form != null) { 49 | $response->setForm($this->form); 50 | 51 | if (!$request->request->has($this->config[self::KEY_FIELD_FORM])) { 52 | throw new UploadException(sprintf( 53 | '%s request field not found in (%s)', 54 | $this->config[self::KEY_FIELD_FORM], 55 | implode(', ', $request->request->keys()) 56 | )); 57 | } 58 | 59 | $submittedValue = $request->request->get($this->config[self::KEY_FIELD_FORM]); 60 | if (is_string($submittedValue)) { 61 | $submittedValue = json_decode($submittedValue, true); 62 | if (!$submittedValue) { 63 | throw new UploadException('Unable to decode JSON'); 64 | } 65 | } elseif (!is_array($submittedValue)) { 66 | throw new UploadException('Unable to parse form data'); 67 | } 68 | 69 | // Submit form data 70 | $formData = $this->createFormData($submittedValue); 71 | $this->form->submit($formData); 72 | if (!$this->form->isValid()) { 73 | return $response; 74 | } 75 | } 76 | 77 | /** @var $uploadedFile \Symfony\Component\HttpFoundation\File\UploadedFile */ 78 | $uploadedFile = $request->files->get($this->config[self::KEY_FIELD_FILE]); 79 | $contents = file_get_contents($uploadedFile->getPathname()); 80 | $file = $this->storageHandler->store($response, $contents, array( 81 | 'metadata' => array( 82 | FileStorage::METADATA_CONTENT_TYPE => $uploadedFile->getMimeType(), 83 | ), 84 | )); 85 | 86 | $response->setFile($file); 87 | 88 | return $response; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Processor/MultipartUploadProcessor.php: -------------------------------------------------------------------------------- 1 | checkHeaders($request); 25 | 26 | // Create the response 27 | $result = new UploadResult(); 28 | $result->setRequest($request); 29 | $result->setConfig($this->config); 30 | $result->setForm($this->form); 31 | 32 | // Submit form data 33 | if ($this->form != null) { 34 | // Get formData 35 | $formData = $this->getFormData($request); 36 | $formData = $this->createFormData($formData); 37 | 38 | $this->form->submit($formData); 39 | } 40 | 41 | if ($this->form === null || $this->form->isValid()) { 42 | list($contentType, $content) = $this->getContent($request); 43 | 44 | $file = $this->storageHandler->store($result, $content, array( 45 | 'metadata' => array( 46 | FileStorage::METADATA_CONTENT_TYPE => $contentType, 47 | ), 48 | )); 49 | 50 | $result->setFile($file); 51 | } 52 | 53 | return $result; 54 | } 55 | 56 | /** 57 | * Get the form data from the request. 58 | * 59 | * Note: MUST be called before getContent, and just one time. 60 | * 61 | * @param Request $request 62 | * 63 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 64 | * 65 | * @return array 66 | */ 67 | protected function getFormData(Request $request) 68 | { 69 | list($boundaryContentType, $boundaryContent) = $this->getPart($request); 70 | 71 | $expectedContentType = 'application/json'; 72 | if (substr($boundaryContentType, 0, strlen($expectedContentType)) != $expectedContentType) { 73 | throw new UploadProcessorException(sprintf( 74 | 'Expected content type of first part is %s. Found %s', 75 | $expectedContentType, 76 | $boundaryContentType 77 | )); 78 | } 79 | 80 | $jsonContent = json_decode($boundaryContent, true); 81 | if ($jsonContent === null) { 82 | throw new UploadProcessorException('Unable to parse JSON'); 83 | } 84 | 85 | return $jsonContent; 86 | } 87 | 88 | /** 89 | * Get the content part of the request. 90 | * 91 | * Note: MUST be called after getFormData, and just one time. 92 | * 93 | * @param \Symfony\Component\HttpFoundation\Request $request 94 | * @param Request $request 95 | * 96 | * @return array 97 | */ 98 | protected function getContent(Request $request) 99 | { 100 | return $this->getPart($request); 101 | } 102 | 103 | /** 104 | * Check multipart headers. 105 | * 106 | * @param Request $request 107 | * @param array $headers 108 | * 109 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 110 | */ 111 | protected function checkHeaders(Request $request, array $headers = array()) 112 | { 113 | list($contentType) = $this->parseContentTypeAndBoundary($request); 114 | 115 | $expectedContentType = 'multipart/related'; 116 | if ($contentType != $expectedContentType) { 117 | throw new UploadProcessorException(sprintf( 118 | 'Content-Type must be %s', 119 | $expectedContentType 120 | )); 121 | } 122 | 123 | parent::checkHeaders($request, array('Content-Type', 'Content-Length')); 124 | } 125 | 126 | /** 127 | * Get a part of request. 128 | * 129 | * @param Request $request 130 | * 131 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 132 | * 133 | * @return array 134 | */ 135 | protected function getPart(Request $request) 136 | { 137 | list($contentType, $boundary) = $this->parseContentTypeAndBoundary($request); 138 | $content = $this->getRequestPart($request, $boundary); 139 | 140 | if (empty($content)) { 141 | throw new UploadProcessorException(sprintf('An empty content found')); 142 | } 143 | 144 | $headerLimitation = strpos($content, "\r\n\r\n") + 1; 145 | if ($headerLimitation == -1) { 146 | throw new UploadProcessorException('Unable to determine headers limit'); 147 | } 148 | 149 | $contentType = null; 150 | $headersContent = substr($content, 0, $headerLimitation); 151 | $headersContent = trim($headersContent); 152 | $body = substr($content, $headerLimitation); 153 | $body = trim($body); 154 | 155 | foreach (explode("\r\n", $headersContent) as $header) { 156 | $parts = explode(':', $header); 157 | if (count($parts) != 2) { 158 | continue; 159 | } 160 | 161 | $name = trim($parts[0]); 162 | if (strtolower($name) == 'content-type') { 163 | $contentType = trim($parts[1]); 164 | break; 165 | } 166 | } 167 | 168 | return array($contentType, $body); 169 | } 170 | 171 | /** 172 | * Get part of a resource. 173 | * 174 | * @param Request $request 175 | * @param $boundary 176 | * 177 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 178 | * 179 | * @return string 180 | */ 181 | protected function getRequestPart(Request $request, $boundary) 182 | { 183 | $contentHandler = $this->getRequestContentHandler($request); 184 | 185 | $delimiter = '--'.$boundary."\r\n"; 186 | $endDelimiter = '--'.$boundary.'--'; 187 | $boundaryCount = 0; 188 | $content = ''; 189 | while (!$contentHandler->eof()) { 190 | $line = $contentHandler->gets(); 191 | if ($line === false) { 192 | throw new UploadProcessorException('An error appears while reading input'); 193 | } 194 | 195 | if ($boundaryCount == 0) { 196 | if ($line != $delimiter) { 197 | if ($contentHandler->getCursor() == strlen($line)) { 198 | throw new UploadProcessorException('Expected boundary delimiter'); 199 | } 200 | } else { 201 | continue; 202 | } 203 | 204 | ++$boundaryCount; 205 | } elseif ($line == $delimiter) { 206 | break; 207 | } elseif ($line == $endDelimiter || $line == $endDelimiter."\r\n") { 208 | break; 209 | } 210 | 211 | $content .= $line; 212 | } 213 | 214 | return trim($content); 215 | } 216 | 217 | /** 218 | * Parse the content type and boudary from Content-Type header. 219 | * 220 | * @param Request $request 221 | * 222 | * @return array 223 | * 224 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 225 | */ 226 | protected function parseContentTypeAndBoundary(Request $request) 227 | { 228 | $contentParts = explode(';', $request->headers->get('Content-Type')); 229 | if (count($contentParts) != 2) { 230 | throw new UploadProcessorException('Boundary may be missing'); 231 | } 232 | 233 | $contentType = trim($contentParts[0]); 234 | $boundaryPart = trim($contentParts[1]); 235 | 236 | $shouldStart = 'boundary='; 237 | if (substr($boundaryPart, 0, strlen($shouldStart)) != $shouldStart) { 238 | throw new UploadProcessorException('Boundary is not set'); 239 | } 240 | 241 | $boundary = substr($boundaryPart, strlen($shouldStart)); 242 | if (substr($boundary, 0, 1) == '"' && substr($boundary, -1) == '"') { 243 | $boundary = substr($boundary, 1, -1); 244 | } 245 | 246 | return array($contentType, $boundary); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /Processor/ProcessorInterface.php: -------------------------------------------------------------------------------- 1 | em = $em; 46 | $this->resumableEntity = $resumableEntity; 47 | } 48 | 49 | /** 50 | * @param \Symfony\Component\HttpFoundation\Request $request 51 | * 52 | * @throws \Exception|\SRIO\RestUploadBundle\Exception\UploadException 53 | * 54 | * @return UploadResult 55 | */ 56 | public function handleRequest(Request $request) 57 | { 58 | if (empty($this->resumableEntity)) { 59 | throw new UploadProcessorException(sprintf( 60 | 'You must configure the "%s" option', 61 | 'resumable_entity' 62 | )); 63 | } 64 | 65 | if ($request->query->has(self::PARAMETER_UPLOAD_ID)) { 66 | $this->checkHeaders($request, array('Content-Length')); 67 | 68 | $uploadId = $request->query->get(self::PARAMETER_UPLOAD_ID); 69 | 70 | $repository = $this->getRepository(); 71 | $resumableUpload = $repository->findOneBy(array( 72 | 'sessionId' => $uploadId, 73 | )); 74 | 75 | if ($resumableUpload == null) { 76 | throw new UploadProcessorException('Unable to find upload session'); 77 | } 78 | 79 | return $this->handleResume($request, $resumableUpload); 80 | } 81 | 82 | return $this->handleStartSession($request); 83 | } 84 | 85 | /** 86 | * Handle a start session. 87 | * 88 | * @param Request $request 89 | * 90 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 91 | * 92 | * @return UploadResult 93 | */ 94 | protected function handleStartSession(Request $request) 95 | { 96 | // Check that needed headers exists 97 | $this->checkHeaders($request, array('Content-Type', 'X-Upload-Content-Type', 'X-Upload-Content-Length')); 98 | $expectedContentType = 'application/json'; 99 | if (substr($request->headers->get('Content-Type'), 0, strlen($expectedContentType)) != $expectedContentType) { 100 | throw new UploadProcessorException(sprintf( 101 | 'Expected content type is %s. Found %s', 102 | $expectedContentType, 103 | $request->headers->get('Content-Type') 104 | )); 105 | } 106 | 107 | // Create the result object 108 | $result = new UploadResult(); 109 | $result->setRequest($request); 110 | $result->setConfig($this->config); 111 | $result->setForm($this->form); 112 | 113 | $formData = array(); 114 | if ($this->form != null) { 115 | // Submit form data 116 | $data = json_decode($request->getContent(), true); 117 | $formData = $this->createFormData($data); 118 | $this->form->submit($formData); 119 | } 120 | 121 | if ($this->form == null || $this->form->isValid()) { 122 | // Form is valid, store it 123 | $repository = $this->getRepository(); 124 | $className = $repository->getClassName(); 125 | 126 | // Create file from storage handler 127 | $file = $this->storageHandler->store($result, '', array( 128 | 'metadata' => array( 129 | FileStorage::METADATA_CONTENT_TYPE => $request->headers->get('X-Upload-Content-Type'), 130 | ), 131 | )); 132 | 133 | /** @var $resumableUpload ResumableUploadSession */ 134 | $resumableUpload = new $className(); 135 | 136 | $resumableUpload->setData(serialize($formData)); 137 | $resumableUpload->setStorageName($file->getStorage()->getName()); 138 | $resumableUpload->setFilePath($file->getFile()->getName()); 139 | $resumableUpload->setSessionId($this->createSessionId()); 140 | $resumableUpload->setContentType($request->headers->get('X-Upload-Content-Type')); 141 | $resumableUpload->setContentLength($request->headers->get('X-Upload-Content-Length')); 142 | 143 | // Store resumable session 144 | $this->em->persist($resumableUpload); 145 | $this->em->flush($resumableUpload); 146 | 147 | // Compute redirect location path 148 | $location = $request->getPathInfo().'?'.http_build_query(array_merge($request->query->all(), array( 149 | self::PARAMETER_UPLOAD_ID => $resumableUpload->getSessionId(), 150 | ))); 151 | 152 | $response = new Response(null); 153 | $response->headers->set('Location', $location); 154 | 155 | $result->setResponse($response); 156 | } 157 | 158 | return $result; 159 | } 160 | 161 | /** 162 | * Handle an upload resume. 163 | * 164 | * @param Request $request 165 | * @param ResumableUploadSession $uploadSession 166 | * 167 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 168 | * 169 | * @return UploadResult 170 | */ 171 | protected function handleResume(Request $request, ResumableUploadSession $uploadSession) 172 | { 173 | $filePath = $uploadSession->getFilePath(); 174 | 175 | $context = new UploadContext(); 176 | $context->setStorageName($uploadSession->getStorageName()); 177 | $file = $this->storageHandler->getFilesystem($context)->get($filePath); 178 | $context->setFile(new UploadedFile( 179 | $this->storageHandler->getStorage($context), 180 | $file 181 | )); 182 | 183 | $contentLength = $request->headers->get('Content-Length'); 184 | if ($request->headers->has('Content-Range')) { 185 | $range = $this->parseContentRange($request->headers->get('Content-Range')); 186 | 187 | if ($range['total'] != $uploadSession->getContentLength()) { 188 | throw new UploadProcessorException(sprintf( 189 | 'File size must be "%d", range total length is %d', 190 | $uploadSession->getContentLength(), 191 | $range['total'] 192 | )); 193 | } elseif ($range['start'] === '*') { 194 | if ($contentLength == 0) { 195 | $file = $this->storageHandler->getFilesystem($context)->get($filePath); 196 | 197 | return $this->requestUploadStatus($context, $uploadSession, $file, $range); 198 | } 199 | 200 | throw new UploadProcessorException('Content-Length must be 0 if asking upload status'); 201 | } 202 | 203 | $uploaded = $this->storageHandler->getFilesystem($context)->getSize($filePath); 204 | if ($range['start'] != $uploaded) { 205 | throw new UploadProcessorException(sprintf( 206 | 'Unable to start at %d while uploaded is %d', 207 | $range['start'], 208 | $uploaded 209 | )); 210 | } 211 | } else { 212 | $range = array( 213 | 'start' => 0, 214 | 'end' => $uploadSession->getContentLength() - 1, 215 | 'total' => $uploadSession->getContentLength() - 1, 216 | ); 217 | } 218 | 219 | // Handle upload from 220 | $handler = $this->getRequestContentHandler($request); 221 | $stream = $this->storageHandler->getFilesystem($context)->getStreamCopy($filePath); 222 | 223 | fseek($stream, $range['start']); 224 | $wrote = 0; 225 | while (!$handler->eof()) { 226 | if (($bytes = fwrite($stream, $handler->gets())) !== false) { 227 | $wrote += $bytes; 228 | } else { 229 | throw new UploadProcessorException('Unable to write to file'); 230 | } 231 | } 232 | 233 | // Get file in context and its size 234 | $uploadedFile = $this->storageHandler->storeStream($context, $stream, array( 235 | 'metadata' => array( 236 | FileStorage::METADATA_CONTENT_TYPE => $request->headers->get('X-Upload-Content-Type'), 237 | ), 238 | ), true); 239 | fclose($stream); 240 | 241 | $file = $uploadedFile->getFile(); 242 | $size = $file->getSize(); 243 | 244 | // If upload is completed, create the upload file, else 245 | // return like the request upload status 246 | if ($size < $uploadSession->getContentLength()) { 247 | return $this->requestUploadStatus($context, $uploadSession, $file, $range); 248 | } elseif ($size == $uploadSession->getContentLength()) { 249 | return $this->handleCompletedUpload($context, $uploadSession, $file); 250 | } else { 251 | throw new UploadProcessorException('Written file size is greater that expected Content-Length'); 252 | } 253 | } 254 | 255 | /** 256 | * Handle a completed upload. 257 | * 258 | * @param \SRIO\RestUploadBundle\Upload\UploadContext $context 259 | * @param ResumableUploadSession $uploadSession 260 | * @param FileAdapterInterface $file 261 | * 262 | * @return UploadResult 263 | */ 264 | protected function handleCompletedUpload(UploadContext $context, ResumableUploadSession $uploadSession, FileAdapterInterface $file) 265 | { 266 | $result = new UploadResult(); 267 | $result->setForm($this->form); 268 | 269 | if ($this->form != null) { 270 | // Submit the form data 271 | $formData = unserialize($uploadSession->getData()); 272 | $this->form->submit($formData); 273 | } 274 | 275 | if ($this->form == null || $this->form->isValid()) { 276 | // Create the uploaded file 277 | $uploadedFile = new UploadedFile( 278 | $this->storageHandler->getStorage($context), 279 | $file 280 | ); 281 | 282 | $result->setFile($uploadedFile); 283 | } 284 | 285 | return $result; 286 | } 287 | 288 | /** 289 | * Return the upload status. 290 | * 291 | * @param \SRIO\RestUploadBundle\Upload\UploadContext $context 292 | * @param ResumableUploadSession $uploadSession 293 | * @param FileAdapterInterface $file 294 | * @param array $range 295 | * 296 | * @return UploadResult 297 | */ 298 | protected function requestUploadStatus(UploadContext $context, ResumableUploadSession $uploadSession, FileAdapterInterface $file, array $range) 299 | { 300 | if (!$file->exists()) { 301 | $length = 0; 302 | } else { 303 | $length = $file->getSize(); 304 | } 305 | 306 | $response = new Response(null, $length == $range['total'] ? 201 : 308); 307 | 308 | if ($length < 1) { 309 | $length = 1; 310 | } 311 | 312 | $response->headers->set('Range', '0-'.($length - 1)); 313 | 314 | $result = new UploadResult(); 315 | $result->setResponse($response); 316 | 317 | return $result; 318 | } 319 | 320 | /** 321 | * Parse the Content-Range header. 322 | * 323 | * It returns an array with these keys: 324 | * - `start` Start index of range 325 | * - `end` End index of range 326 | * - `total` Total number of bytes 327 | * 328 | * @param string $contentRange 329 | * 330 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 331 | * 332 | * @return array 333 | */ 334 | protected function parseContentRange($contentRange) 335 | { 336 | $contentRange = trim($contentRange); 337 | if (!preg_match('#^bytes (\*|([0-9]+)-([0-9]+))/([0-9]+)$#', $contentRange, $matches)) { 338 | throw new UploadProcessorException('Invalid Content-Range header. Must start with "bytes ", range and total length'); 339 | } 340 | 341 | $range = array( 342 | 'start' => $matches[1] === '*' ? '*' : ($matches[2] === '' ? null : (int) $matches[2]), 343 | 'end' => $matches[3] === '' ? null : (int) $matches[3], 344 | 'total' => (int) $matches[4], 345 | ); 346 | 347 | if (empty($range['total'])) { 348 | throw new UploadProcessorException('Content-Range total length not found'); 349 | } 350 | if ($range['start'] === '*') { 351 | if ($range['end'] !== null) { 352 | throw new UploadProcessorException('Content-Range end must not be present if start is "*"'); 353 | } 354 | } elseif ($range['start'] === null || $range['end'] === null) { 355 | throw new UploadProcessorException('Content-Range end or start is empty'); 356 | } elseif ($range['start'] > $range['end']) { 357 | throw new UploadProcessorException('Content-Range start must be lower than end'); 358 | } elseif ($range['end'] > $range['total']) { 359 | throw new UploadProcessorException('Content-Range end must be lower or equal to total length'); 360 | } 361 | 362 | return $range; 363 | } 364 | 365 | /** 366 | * Get resumable upload session entity repository. 367 | * 368 | * @return \Doctrine\ORM\EntityRepository 369 | */ 370 | protected function getRepository() 371 | { 372 | return $this->em->getRepository($this->resumableEntity); 373 | } 374 | 375 | /** 376 | * Create a session ID. 377 | */ 378 | protected function createSessionId() 379 | { 380 | return uniqid(); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /Processor/SimpleUploadProcessor.php: -------------------------------------------------------------------------------- 1 | checkHeaders($request, array('Content-Type')); 25 | 26 | $result = new UploadResult(); 27 | $result->setForm($this->form); 28 | $result->setRequest($request); 29 | $result->setConfig($this->config); 30 | 31 | // Submit form data 32 | if ($this->form !== null) { 33 | $formData = $this->createFormData($request->query->all()); 34 | $this->form->submit($formData); 35 | } 36 | 37 | if ($this->form == null || $this->form->isValid()) { 38 | $content = $request->getContent(); 39 | 40 | // Nothing to store 41 | if (empty($content)) { 42 | throw new UploadException('There is no content to upload'); 43 | } 44 | 45 | $file = $this->storageHandler->store($result, $content, array( 46 | 'metadata' => array( 47 | FileStorage::METADATA_CONTENT_TYPE => $request->headers->get('Content-Type'), 48 | ), 49 | )); 50 | 51 | $result->setFile($file); 52 | } 53 | 54 | return $result; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SRIORestUploadBundle 2 | 3 | [![Build Status](https://api.travis-ci.org/sroze/SRIORestUploadBundle.png)](https://travis-ci.org/sroze/SRIORestUploadBundle) 4 | 5 | This bundle provide a simple ways to handle uploads on the server side. 6 | 7 | Currently, it supports the simple, form-data, multipart and resumable ways. 8 | 9 | ## Getting started 10 | 11 | Using [Gaufrette](https://github.com/KnpLabs/Gaufrette) as storage layer, you can handle file uploads and store files on many places such as a local file system, an Amazon S3 bucket, ... 12 | 13 | - [Installation](Resources/doc/installation.md) 14 | - [Usage](Resources/doc/usage.md) 15 | - [Advanced usage](Resources/doc/advanced.md) 16 | - [Upload ways summary](Resources/doc/upload-ways.md) 17 | - [Configuration reference](Resources/doc/reference.md) 18 | 19 | ## Testing 20 | 21 | Tests are run with [PHPUnit](http://phpunit.de). Once you installed dependencies with composer, then: 22 | 23 | - Create a database, allow access to a user, and set configuration in `Tests/Fixtures/App/app/config/parameters.yml` file 24 | - Create the database schema for the `test` environment 25 | ```sh 26 | php Tests/Fixtures/App/app/console doctrine:schema:update --force --env=test 27 | ``` 28 | - Run PHPUnit 29 | ```sh 30 | phpunit 31 | ``` 32 | -------------------------------------------------------------------------------- /Request/RequestContentHandler.php: -------------------------------------------------------------------------------- 1 | request = $request; 32 | $this->cursor = 0; 33 | } 34 | 35 | /** 36 | * Get a line. 37 | * 38 | * If false is return, it's the end of file. 39 | * 40 | * @return string|bool 41 | */ 42 | public function gets() 43 | { 44 | $content = $this->getContent(); 45 | if (is_resource($content)) { 46 | $line = fgets($content); 47 | $this->cursor = ftell($content); 48 | 49 | return $line; 50 | } 51 | 52 | $next = strpos($content, "\r\n", $this->cursor); 53 | $eof = $next < 0 || $next === false; 54 | 55 | if ($eof) { 56 | $line = substr($content, $this->cursor); 57 | } else { 58 | $length = $next - $this->cursor + strlen("\r\n"); 59 | $line = substr($content, $this->cursor, $length); 60 | } 61 | 62 | $this->cursor = $eof ? -1 : $next + strlen("\r\n"); 63 | 64 | return $line; 65 | } 66 | 67 | /** 68 | * @return int 69 | */ 70 | public function getCursor() 71 | { 72 | return $this->cursor; 73 | } 74 | 75 | /** 76 | * Is end of file ? 77 | * 78 | * @return bool 79 | */ 80 | public function eof() 81 | { 82 | return $this->cursor == -1 || (is_resource($this->getContent()) && feof($this->getContent())); 83 | } 84 | 85 | /** 86 | * Get request content. 87 | * 88 | * @return resource|string 89 | * 90 | * @throws \RuntimeException 91 | */ 92 | public function getContent() 93 | { 94 | if ($this->content === null) { 95 | try { 96 | $this->content = $this->request->getContent(true); 97 | } catch (\LogicException $e) { 98 | $this->content = $this->request->getContent(false); 99 | 100 | if (!$this->content) { 101 | throw new \RuntimeException('Unable to get request content'); 102 | } 103 | } 104 | } 105 | 106 | return $this->content; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Request/RequestContentHandlerInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Resources/config/handlers.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | SRIO\RestUploadBundle\Upload\UploadHandler 8 | SRIO\RestUploadBundle\Upload\StorageHandler 9 | SRIO\RestUploadBundle\Voter\StorageVoter 10 | 11 | 12 | 13 | 14 | %srio_rest_upload.upload_type_parameter% 15 | 16 | 17 | 18 | 19 | 20 | 21 | %srio_rest_upload.default_storage% 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Resources/config/processors.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | SRIO\RestUploadBundle\Processor\AbstractUploadProcessor 8 | SRIO\RestUploadBundle\Processor\SimpleUploadProcessor 9 | SRIO\RestUploadBundle\Processor\MultipartUploadProcessor 10 | SRIO\RestUploadBundle\Processor\ResumableUploadProcessor 11 | SRIO\RestUploadBundle\Processor\FormDataUploadProcessor 12 | 13 | 14 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 24 | 25 | 26 | 28 | 29 | 30 | 32 | 33 | %srio_rest_upload.resumable_entity_class% 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /Resources/config/storage.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | SRIO\RestUploadBundle\Storage\FlysystemFilesystemAdapter 8 | SRIO\RestUploadBundle\Storage\GaufretteFilesystemAdapter 9 | 10 | 11 | 12 | 13 | null 14 | 15 | 16 | null 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Resources/config/strategy.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | SRIO\RestUploadBundle\Strategy\DefaultStorageStrategy 8 | SRIO\RestUploadBundle\Strategy\DefaultNamingStrategy 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Resources/doc/advanced.md: -------------------------------------------------------------------------------- 1 | # Advanced usage 2 | 3 | ## Strategies 4 | 5 | You can set naming and storage strategies for each defined storage. 6 | ```yml 7 | srio_rest_upload: 8 | storages: 9 | default: 10 | filesystem: gaufrette.default_filesystem 11 | naming_strategy: your_naming_strategy_service 12 | storage_strategy: your_storage_strategy_service 13 | ``` 14 | 15 | ### Naming strategy 16 | 17 | The naming strategy is responsible to set the name that the stored file will have. The [default naming strategy](../../Strategy/DefaultNamingStrategy.php) create a random file name. 18 | 19 | To create your own strategy you just have to create a class that implements the `NamingStrategy` interface. Here's an example with a strategy that generate a random file name but with its extension or the default one as fallback. 20 | 21 | ```php 22 | namespace Acme\Storage\Strategy; 23 | 24 | use SRIO\RestUploadBundle\Upload\UploadContext; 25 | use SRIO\RestUploadBundle\Strategy\NamingStrategy; 26 | 27 | class DefaultNamingStrategy implements NamingStrategy 28 | { 29 | const DEFAULT_EXTENSION = 'png'; 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function getName(UploadContext $context) 35 | { 36 | $name = uniqid(); 37 | $extension = self::DEFAULT_EXTENSION; 38 | 39 | if (($request = $context->getRequest()) !== null) { 40 | $files = $request->files->all(); 41 | 42 | /** @var $file \Symfony\Component\HttpFoundation\File\UploadedFile */ 43 | $file = array_pop($files); 44 | 45 | if ($file !== null) { 46 | $parts = explode('.', $file->getClientOriginalName()); 47 | $extension = array_pop($parts); 48 | } 49 | } 50 | 51 | return $name.'.'.$extension; 52 | } 53 | } 54 | ``` 55 | 56 | Then, define a service and change the `naming_strategy` of your storage configuration with the created service ID. 57 | 58 | ### Storage strategy 59 | 60 | It defines the (sub)directory in which the file will be created in your filesystem. 61 | 62 | The storage strategy is working the same way than the naming strategy: create a service with a class that implements `StorageStrategy` and set the `storage_strategy` configuration of your storage with the created service. 63 | 64 | ## Create a custom handler 65 | 66 | You can easily create your custom upload providers (and feel free to _PR_ them on GitHub) by creating a [tagged service](http://symfony.com/doc/current/components/dependency_injection/tags.html) with the `rest_upload.processor` tag 67 | 68 | ```yml 69 | 70 | Acme\AcmeBundle\Processor\MyUploadProcessor 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | ``` 80 | 81 | Note the `uploadType` attribute that define the unique name of the upload way, set in the `uploadType` query parameters. 82 | 83 | Your `MyUploadProcessor` class should then implements the [`ProcessorInterface`](../../Processor/ProcessorInterface.php) or extends the [`AbstractUploadProcessor`](../../Processor/AbstractUploadProcessor.php) 84 | 85 | -------------------------------------------------------------------------------- /Resources/doc/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | First, you need to install [KnpGaufretteBundle](https://github.com/KnpLabs/KnpGaufretteBundle), a Symfony integration of Gaufrette which will handle the file storage on places your want. 4 | 5 | ## Add SRIORestUploadBundle in your dependencies 6 | 7 | In your `composer.json` file, add `srio/rest-upload-bundle`: 8 | ```json 9 | { 10 | "require": { 11 | "srio/rest-upload-bundle": "~2.0.0" 12 | } 13 | } 14 | ``` 15 | 16 | Then, update your dependencies: 17 | ``` 18 | composer update srio/rest-upload-bundle 19 | ``` 20 | 21 | ## Enable the bundle in your kernel 22 | 23 | ```php 24 | // app/AppKernel.php 25 | public function registerBundles() 26 | { 27 | $bundles = array( 28 | // ... 29 | new SRIO\RestUploadBundle\SRIORestUploadBundle(), 30 | ); 31 | } 32 | ``` 33 | 34 | ## Create the Gaufrette filesystem 35 | 36 | In your configuration file, create your Gaufrette filesystem. Let's start with a local filesystem storage in the `web/uploads` directory. 37 | 38 | ```yml 39 | # app/config/config.yml 40 | 41 | knp_gaufrette: 42 | adapters: 43 | local_uploads: 44 | local: 45 | directory: %kernel.root_dir%/../web/uploads 46 | filesystems: 47 | uploads: 48 | adapter: local_uploads 49 | ``` 50 | 51 | ## Configure the bundle 52 | 53 | Then, we just have to configure the bundle to use the Gaufrette storage: 54 | ``` 55 | srio_rest_upload: 56 | storages: 57 | default: 58 | filesystem: gaufrette.uploads_filesystem 59 | ``` 60 | 61 | If you want to use the resumable upload way, you have to [configure it](upload-ways.md#resumable-configuration). 62 | 63 | Then, [start using it](usage.md). 64 | -------------------------------------------------------------------------------- /Resources/doc/reference.md: -------------------------------------------------------------------------------- 1 | # Configuration reference 2 | 3 | ```yml 4 | srio_rest_upload: 5 | # Define the available storages 6 | storages: 7 | name: 8 | # Filesystem service created by Gaufrette (or your own matching the Gaufrette's interface) 9 | filesystem: fs_service_id 10 | 11 | # Naming strategy service 12 | naming_strategy: srio_rest_upload.naming.default_strategy 13 | 14 | # Storage strategy service 15 | storage_strategy: srio_rest_upload.storage.default_strategy 16 | 17 | # The storage voter, that chose between storage based on upload context 18 | storage_voter: srio_rest_upload.storage_voter.default 19 | 20 | # The default storage name. With the default storage voter, it'll use 21 | # the first defined storage if value is null 22 | default_storage: ~ 23 | 24 | # If you want to use the resumable upload way, you must set 25 | # the class name of your entity which store the upload sessions. 26 | resumable_entity_class: ~ 27 | 28 | # Parameter the define the upload way, internally the provider selector 29 | upload_type_parameter: uploadType 30 | ``` 31 | 32 | The [Advanced usage](advanced.md) section explain the naming and storage strategies. 33 | -------------------------------------------------------------------------------- /Resources/doc/upload-ways.md: -------------------------------------------------------------------------------- 1 | # Upload ways 2 | 3 | This is a summary of currently supported upload ways. 4 | 5 | - [Simple](#simple-upload-way): Send binary data to an URL and use query parameters to submit additional data. 6 | - [Multipart](#multipart-upload-way): Send both JSON and binary data using the [multipart Content-Type](http://www.w3.org/Protocols/rfc1341/7_2_Multipart.html). 7 | - [FormData](#formdata-upload-way): Matches the classic browser file upload 8 | - [Resumable](#resumable-upload-way): start a resumable upload session by sending JSON data and then send file entirely or by chunks. It allow to restart a failed upload where it stops. 9 | 10 | ## Simple upload way 11 | 12 | The most straightforward method for uploading a file is by making a simple upload request. This option is a good choice when: 13 | - The file is small enough to upload again in its entirety if the connection fails 14 | - There is no or a very small amount of metadata to send 15 | 16 | To use simple upload, make a `POST` or `PUT` request to the upload method's URI and add the query parameter `uploadType=simple`. 17 | The HTTP headers to use when making a simple upload request include: 18 | - `Content-Type`. Set the media content-type 19 | - `Content-Length`. Set to the number of bytes you are uploading. 20 | 21 | ### Example 22 | 23 | The following example shows the use of a simple photo upload request for an upload path that would be `/upload`: 24 | 25 | ``` 26 | POST /upload?uploadType=simple HTTP/1.1 27 | Host: www.example.com 28 | Content-Type: image/jpeg 29 | Content-Length: number_of_bytes_in_JPEG_file 30 | 31 | JPEG data 32 | ``` 33 | 34 | ## Multipart upload way 35 | 36 | If you have metadata that you want to send along with the data to upload, you can make a single `multipart/related` request. This is a good choice if the data you are sending is small enough to upload again in its entirety if the connection fails. 37 | To use multipart upload, make a `POST` or `PUT` request to the upload method's URI and add the query parameter `uploadType=multipart`. 38 | 39 | The top-level HTTP headers to use when making a multipart upload request include: 40 | - `Content-Type`. Set to `multipart/related` and include the boundary string you're using to identify the parts of the request. 41 | - `Content-Length`. Set to the total number of bytes in the request body. 42 | 43 | The body of the request is formatted as a `multipart/related` content type [RFC2387] and contains exactly two parts. The parts are identified by a boundary string, and the final boundary string is followed by two hyphens. 44 | 45 | Each part of the multipart request needs an additional `Content-Type` header: 46 | - **Metadata part**: Must come first, and Content-Type must match one of the the accepted metadata formats. 47 | - **Media part**: Must come second, and Content-Type must match one the method's accepted media MIME types. 48 | 49 | ### Example 50 | 51 | The following example shows the use of a multipart upload request for an upload path that would be `/upload`: 52 | 53 | ``` 54 | POST /upload?uploadType=multipart HTTP/1.1 55 | Host: www.example.com 56 | Content-Type: multipart/related; boundary="foo_bar_baz" 57 | Content-Length: number_of_bytes_in_entire_request_body 58 | 59 | --foo_bar_baz 60 | Content-Type: application/json; charset=UTF-8 61 | 62 | { 63 | "name": "Some value" 64 | } 65 | 66 | --foo_bar_baz 67 | Content-Type: image/jpeg 68 | 69 | JPEG data 70 | 71 | --foo_bar_baz-- 72 | ``` 73 | 74 | ### FormData upload way 75 | 76 | This may be the most used way to upload files: it matches with the classic form "file" upload. 77 | 78 | You just have to have a field of type `file` named `file` on your form and set the action path to `/upload?uploadType=formData`. 79 | It can ether be used with any XHR upload method. 80 | 81 | ## Resumable upload way 82 | 83 | To upload data files more reliably, you can use the resumable upload protocol. This protocol allows you to resume an upload operation after a communication failure has interrupted the flow of data. It is especially useful if you are transferring large files and the likelihood of a network interruption or some other transmission failure is high, for example, when uploading from a mobile client app. It can also reduce your bandwidth usage in the event of network failures because you don't have to restart large file uploads from the beginning. 84 | 85 | The steps for using resumable upload include: 86 | 87 | 1. [Start a resumable session](#start-resumable). Make an initial request to the upload URI that includes the metadata, if any. 88 | 2. [Save the resumable session URI](#save-session-uri). Save the session URI returned in the response of the initial request; you'll use it for the remaining requests in this session. 89 | 3. [Upload the file](#upload-resumable). Send the media file to the resumable session URI. 90 | 91 | In addition, apps that use resumable upload need to have code to [resume an interrupted upload](#resume-upload). If an upload is interrupted, find out how much data was successfully received, and then resume the upload starting from that point. 92 | 93 | ### Resumable Configuration 94 | 95 | First, you need to configure the bundle. 96 | 97 | #### Create your `ResumableUploadSession` entity 98 | 99 | The entity will contains the resumable upload sessions and is required if you want the resumable way of upload to work. 100 | 101 | ```php 102 | get('srio_rest_upload.upload_handler'); 30 | $result = $uploadHandler->handleRequest($request); 31 | 32 | if (($response = $result->getResponse()) !== null) { 33 | return $response; 34 | } 35 | 36 | if (($file = $result->getFile()) !== null) { 37 | // Store the file path in an entity, call an API, 38 | // do whatever with the uploaded file here. 39 | 40 | return new Response(); 41 | } 42 | 43 | throw new BadRequestHttpException('Unable to handle upload request'); 44 | } 45 | } 46 | ``` 47 | 48 | ## With form 49 | 50 | Because most of the time you may want to link a form to the file upload, you're able to handle it too. 51 | Depending on the [upload way](upload-ways.md) you're using, form data will be fetched from request body or HTTP parameters. 52 | 53 | Here's an example of a controller with a form (it comes directly from tests, [feel free to have a look](../../Tests/Fixtures/Controller/UploadController.php), you'll have all sources): 54 | ```php 55 | class UploadController extends Controller 56 | { 57 | public function uploadAction(Request $request) 58 | { 59 | $form = $this->createForm(new MediaFormType()); 60 | 61 | /** @var $uploadHandler UploadHandler */ 62 | $uploadHandler = $this->get('srio_rest_upload.upload_handler'); 63 | $result = $uploadHandler->handleRequest($request, $form); 64 | 65 | if (($response = $result->getResponse()) != null) { 66 | return $response; 67 | } 68 | 69 | if (!$form->isValid()) { 70 | throw new BadRequestHttpException(); 71 | } 72 | 73 | if (($file = $result->getFile()) !== null) { 74 | /** @var $media Media */ 75 | $media = $form->getData(); 76 | $media->setFile($file); 77 | 78 | $em = $this->getDoctrine()->getManager(); 79 | $em->persist($media); 80 | $em->flush(); 81 | 82 | return new JsonResponse($media); 83 | } 84 | 85 | throw new NotAcceptableHttpException(); 86 | } 87 | } 88 | ``` 89 | 90 | ## On the client side 91 | 92 | Here's a simple example of an upload (which is using the form-data handler) using [AngularJS's `$upload` service](https://github.com/danialfarid/angular-file-upload): 93 | ```js 94 | $upload.upload({ 95 | url: '/path/to/upload?uploadType=formData', 96 | method: 'POST', 97 | file: file 98 | }) 99 | ``` 100 | 101 | -------------------------------------------------------------------------------- /SRIORestUploadBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new ProcessorCompilerPass()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Storage/FileAdapterInterface.php: -------------------------------------------------------------------------------- 1 | name = $name; 44 | $this->filesystem = $filesystem; 45 | $this->storageStrategy = $storageStrategy; 46 | $this->namingStrategy = $namingStrategy; 47 | } 48 | 49 | /** 50 | * Store a file's content. 51 | * 52 | * @param UploadContext $context 53 | * @param string $content 54 | * @param array $config 55 | * @param bool $overwrite 56 | * 57 | * @return UploadedFile 58 | */ 59 | public function store(UploadContext $context, $content, array $config = array(), $overwrite = false) 60 | { 61 | $path = $this->getFilePathFromContext($context); 62 | if ($overwrite === true) { 63 | $this->filesystem->put($path, $content, $config); 64 | } else { 65 | $this->filesystem->write($path, $content, $config); 66 | } 67 | $file = $this->filesystem->get($path); 68 | 69 | return new UploadedFile($this, $file); 70 | } 71 | 72 | /** 73 | * Store a file's content. 74 | * 75 | * @param UploadContext $context 76 | * @param resource $resource 77 | * @param array $config 78 | * @param bool $overwrite 79 | * 80 | * @return UploadedFile 81 | */ 82 | public function storeStream(UploadContext $context, $resource, array $config = array(), $overwrite = false) 83 | { 84 | $path = $this->getFilePathFromContext($context); 85 | if ($overwrite === true) { 86 | $this->filesystem->putStream($path, $resource, $config); 87 | } else { 88 | $this->filesystem->writeStream($path, $resource, $config); 89 | } 90 | $file = $this->filesystem->get($path); 91 | 92 | return new UploadedFile($this, $file); 93 | } 94 | 95 | /** 96 | * Get or creates a file path from UploadContext. 97 | * 98 | * @param UploadContext $context 99 | * 100 | * @return string 101 | */ 102 | protected function getFilePathFromContext(UploadContext $context) 103 | { 104 | if ($context->getFile() != null) { 105 | return $context->getFile()->getFile()->getName(); 106 | } 107 | 108 | $name = $this->namingStrategy->getName($context); 109 | $directory = $this->storageStrategy->getDirectory($context, $name); 110 | $path = $directory.'/'.$name; 111 | 112 | return $path; 113 | } 114 | 115 | /** 116 | * @return FilesystemAdapterInterface 117 | */ 118 | public function getFilesystem() 119 | { 120 | return $this->filesystem; 121 | } 122 | 123 | /** 124 | * @return string 125 | */ 126 | public function getName() 127 | { 128 | return $this->name; 129 | } 130 | 131 | /** 132 | * @return NamingStrategy 133 | */ 134 | public function getNamingStrategy() 135 | { 136 | return $this->namingStrategy; 137 | } 138 | 139 | /** 140 | * @return StorageStrategy 141 | */ 142 | public function getStorageStrategy() 143 | { 144 | return $this->storageStrategy; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Storage/FilesystemAdapterInterface.php: -------------------------------------------------------------------------------- 1 | file = $file; 17 | } 18 | 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function exists() 23 | { 24 | return $this->file->exists(); 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function getSize() 31 | { 32 | return $this->file->getSize(); 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function getName() 39 | { 40 | return $this->file->getPath(); 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function getFile() 47 | { 48 | return $this->file; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Storage/FlysystemFilesystemAdapter.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 22 | } 23 | 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | public function getFilesystem() 28 | { 29 | return $this->filesystem; 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public function getAdapter() 36 | { 37 | return $this->filesystem->getAdapter(); 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function has($path) 44 | { 45 | return $this->filesystem->has($path); 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function get($path) 52 | { 53 | return new FlysystemFileAdapter(new File($this->filesystem, $path)); 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | public function write($path, $content, array $config = array()) 60 | { 61 | try { 62 | return $this->filesystem->write($path, $content, $config); 63 | } catch (FileExistsException $ex) { 64 | throw $this->createFileExistsException($path, $ex); 65 | } 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | public function writeStream($path, $resource, array $config = array()) 72 | { 73 | try { 74 | return $this->filesystem->writeStream($path, $resource, $config); 75 | } catch (FileExistsException $ex) { 76 | throw $this->createFileExistsException($path, $ex); 77 | } 78 | } 79 | 80 | /** 81 | * {@inheritdoc} 82 | */ 83 | public function put($path, $content, array $config = array()) 84 | { 85 | return $this->filesystem->put($path, $content, $config); 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | */ 91 | public function putStream($path, $resource, array $config = array()) 92 | { 93 | return $this->filesystem->putStream($path, $resource, $config); 94 | } 95 | 96 | /** 97 | * {@inheritdoc} 98 | */ 99 | public function read($path) 100 | { 101 | try { 102 | return $this->filesystem->read($path); 103 | } catch (FileNotFoundException $ex) { 104 | throw $this->createFileNotFoundException($path, $ex); 105 | } 106 | } 107 | 108 | /** 109 | * {@inheritdoc} 110 | */ 111 | public function readStream($path) 112 | { 113 | try { 114 | return $this->filesystem->readStream($path); 115 | } catch (FileNotFoundException $ex) { 116 | throw $this->createFileNotFoundException($path, $ex); 117 | } 118 | } 119 | 120 | /** 121 | * {@inheritdoc} 122 | */ 123 | public function delete($path) 124 | { 125 | try { 126 | return $this->filesystem->delete($path); 127 | } catch (FileNotFoundException $ex) { 128 | throw $this->createFileNotFoundException($path, $ex); 129 | } 130 | } 131 | 132 | /** 133 | * {@inheritdoc} 134 | */ 135 | public function getStreamCopy($path) 136 | { 137 | $stream = $this->readStream($path); 138 | 139 | // Neatly overflow into a file on disk after more than 10MBs. 140 | $mbLimit = 10 * 1024 * 1024; 141 | $streamCopy = fopen("php://temp/maxmemory:$mbLimit", 'w+b'); 142 | 143 | stream_copy_to_stream($stream, $streamCopy); 144 | rewind($streamCopy); 145 | 146 | return $streamCopy; 147 | } 148 | 149 | /** 150 | * {@inheritdoc} 151 | */ 152 | public function getModifiedTimestamp($path) 153 | { 154 | if (false === $timestamp = $this->filesystem->getTimestamp($path)) { 155 | throw $this->createFileNotFoundException($path); 156 | } 157 | 158 | return $timestamp; 159 | } 160 | 161 | /** 162 | * {@inheritdoc} 163 | */ 164 | public function getSize($path) 165 | { 166 | if (false === $size = $this->filesystem->getSize($path)) { 167 | throw $this->createFileNotFoundException($path); 168 | } 169 | 170 | return $size; 171 | } 172 | 173 | /** 174 | * {@inheritdoc} 175 | */ 176 | public function getMimeType($path) 177 | { 178 | if (false === $mimeType = $this->filesystem->getMimetype($path)) { 179 | throw $this->createFileNotFoundException($path); 180 | } 181 | 182 | return $mimeType; 183 | } 184 | 185 | protected function createFileNotFoundException($path, $previousEx = null) 186 | { 187 | if ($previousEx === null) { 188 | $previousEx = new FileNotFoundException($path); 189 | } 190 | 191 | return new WrappingFileNotFoundException($previousEx->getMessage(), $previousEx->getCode(), $previousEx); 192 | } 193 | 194 | protected function createFileExistsException($path, $previousEx = null) 195 | { 196 | if ($previousEx === null) { 197 | $previousEx = new FileExistsException($path); 198 | } 199 | 200 | return new WrappingFileExistsException($previousEx->getMessage(), $previousEx->getCode(), $previousEx); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /Storage/GaufretteFileAdapter.php: -------------------------------------------------------------------------------- 1 | file = $file; 17 | } 18 | 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function exists() 23 | { 24 | return $this->file->exists(); 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function getSize() 31 | { 32 | return $this->file->getSize(); 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function getName() 39 | { 40 | return $this->file->getKey(); 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function getFile() 47 | { 48 | return $this->file; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Storage/GaufretteFilesystemAdapter.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | public function getFilesystem() 29 | { 30 | return $this->filesystem; 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function getAdapter() 37 | { 38 | return $this->filesystem->getAdapter(); 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function has($path) 45 | { 46 | return $this->filesystem->has($path); 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | */ 52 | public function get($path) 53 | { 54 | return new GaufretteFileAdapter($this->filesystem->get($path)); 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | public function write($path, $content, array $config = array()) 61 | { 62 | return $this->writeContents($path, $content, $config, false); 63 | } 64 | 65 | /** 66 | * {@inheritdoc} 67 | */ 68 | public function writeStream($path, $resource, array $config = array()) 69 | { 70 | // This is not ideal, stream_get_contents will read the full stream into memory before we can 71 | // flow it into the write function. Watch out with big files and Gaufrette! 72 | return $this->writeContents($path, stream_get_contents($resource, -1, 0), $config, false); 73 | } 74 | 75 | /** 76 | * {@inheritdoc} 77 | */ 78 | public function put($path, $content, array $config = array()) 79 | { 80 | return $this->writeContents($path, $content, $config, true); 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public function putStream($path, $resource, array $config = array()) 87 | { 88 | // This is not ideal, stream_get_contents will read the full stream into memory before we can 89 | // flow it into the write function. Watch out with big files and Gaufrette! 90 | return $this->writeContents($path, stream_get_contents($resource, -1, 0), $config, true); 91 | } 92 | 93 | /** 94 | * General function for all writes. 95 | * 96 | * @param $path 97 | * @param $content 98 | * @param array $config 99 | * @param bool $overwrite 100 | * 101 | * @return bool 102 | */ 103 | protected function writeContents($path, $content, array $config = array(), $overwrite = false) 104 | { 105 | if (!empty($config['metadata'])) { 106 | $adapter = $this->getAdapter(); 107 | if ($adapter instanceof MetadataSupporter) { 108 | $allowed = empty($config['allowedMetadataKeys']) 109 | ? array(FileStorage::METADATA_CONTENT_TYPE) 110 | : array_merge($config['allowedMetadataKeys'], array(FileStorage::METADATA_CONTENT_TYPE)); 111 | 112 | $adapter->setMetadata($path, $this->resolveMetadataMap($allowed, $config['metadata'])); 113 | } 114 | } 115 | 116 | try { 117 | $this->filesystem->write($path, $content, $overwrite); 118 | 119 | return true; 120 | } catch (\RuntimeException $ex) { 121 | return false; 122 | } 123 | } 124 | 125 | /** 126 | * Resolve the metadata map. 127 | * 128 | * @param array $allowedMetadataKeys 129 | * @param array $metadataMap 130 | * 131 | * @return array 132 | */ 133 | protected function resolveMetadataMap(array $allowedMetadataKeys, array $metadataMap) 134 | { 135 | $map = array(); 136 | 137 | foreach ($allowedMetadataKeys as $key) { 138 | if (array_key_exists($key, $metadataMap)) { 139 | $map[$key] = $metadataMap[$key]; 140 | } 141 | } 142 | 143 | return $map; 144 | } 145 | 146 | /** 147 | * {@inheritdoc} 148 | */ 149 | public function read($path) 150 | { 151 | try { 152 | $this->filesystem->read($path); 153 | 154 | return true; 155 | } catch (FileNotFound $ex) { 156 | throw $this->createFileNotFoundException($path, $ex); 157 | } catch (\RuntimeException $ex) { 158 | return false; 159 | } 160 | } 161 | 162 | /** 163 | * {@inheritdoc} 164 | */ 165 | public function readStream($path) 166 | { 167 | if (!$this->filesystem->has($path)) { 168 | throw $this->createFileNotFoundException($path); 169 | } 170 | 171 | // If castable to a real stream (local filesystem for instance) use that stream. 172 | $streamWrapper = $this->filesystem->createStream($path); 173 | $streamWrapper->open(new StreamMode('rb')); 174 | $stream = $streamWrapper->cast(0); 175 | 176 | if ($stream === false) { 177 | // This is not ideal, read will first read the full file into memory before we can 178 | // flow it into the temp stream. Watch out with big files and Gaufrette! 179 | $stream = fopen('php://temp', 'w+b'); 180 | fwrite($stream, $this->read($path)); 181 | rewind($stream); 182 | } 183 | 184 | return $stream; 185 | } 186 | 187 | /** 188 | * {@inheritdoc} 189 | */ 190 | public function getStreamCopy($path) 191 | { 192 | if (!$this->filesystem->has($path)) { 193 | throw $this->createFileNotFoundException($path); 194 | } 195 | 196 | // If castable to a real stream (local filesystem for instance) use that stream. 197 | $streamWrapper = $this->filesystem->createStream($path); 198 | $streamWrapper->open(new StreamMode('rb')); 199 | $stream = $streamWrapper->cast(0); 200 | 201 | // Neatly overflow into a file on disk after more than 10MBs. 202 | $mbLimit = 10 * 1024 * 1024; 203 | $streamCopy = fopen("php://temp/maxmemory:$mbLimit", 'w+b'); 204 | if ($stream === false) { 205 | // This is not ideal, read will first read the full file into memory before we can 206 | // flow it into the temp stream. Watch out with big files and Gaufrette! 207 | $data = $this->read($path); 208 | fwrite($streamCopy, $data); 209 | } else { 210 | stream_copy_to_stream($stream, $streamCopy); 211 | } 212 | 213 | rewind($streamCopy); 214 | 215 | return $streamCopy; 216 | } 217 | 218 | /** 219 | * {@inheritdoc} 220 | */ 221 | public function delete($path) 222 | { 223 | try { 224 | return $this->filesystem->delete($path); 225 | } catch (FileNotFound $ex) { 226 | throw $this->createFileNotFoundException($path, $ex); 227 | } 228 | } 229 | 230 | /** 231 | * {@inheritdoc} 232 | */ 233 | public function getModifiedTimestamp($path) 234 | { 235 | try { 236 | return $this->filesystem->mtime($path); 237 | } catch (FileNotFound $ex) { 238 | throw $this->createFileNotFoundException($path, $ex); 239 | } 240 | } 241 | 242 | /** 243 | * {@inheritdoc} 244 | */ 245 | public function getSize($path) 246 | { 247 | try { 248 | return $this->filesystem->size($path); 249 | } catch (FileNotFound $ex) { 250 | throw $this->createFileNotFoundException($path, $ex); 251 | } 252 | } 253 | 254 | /** 255 | * {@inheritdoc} 256 | */ 257 | public function getMimeType($path) 258 | { 259 | try { 260 | return $this->filesystem->mimeType($path); 261 | } catch (FileNotFound $ex) { 262 | throw $this->createFileNotFoundException($path, $ex); 263 | } 264 | } 265 | 266 | protected function createFileNotFoundException($path, $previousEx = null) 267 | { 268 | if ($previousEx === null) { 269 | $previousEx = new FileNotFound($path); 270 | } 271 | 272 | return new WrappingFileNotFoundException($previousEx->getMessage(), $previousEx->getCode(), $previousEx); 273 | } 274 | 275 | protected function createFileExistsException($path, $previousEx = null) 276 | { 277 | if ($previousEx === null) { 278 | $previousEx = new FileAlreadyExists($path); 279 | } 280 | 281 | return new WrappingFileExistsException($previousEx->getMessage(), $previousEx->getCode(), $previousEx); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /Storage/UploadedFile.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 24 | $this->file = $file; 25 | } 26 | 27 | /** 28 | * @return FileAdapterInterface 29 | */ 30 | public function getFile() 31 | { 32 | return $this->file; 33 | } 34 | 35 | /** 36 | * @return \SRIO\RestUploadBundle\Storage\FileStorage 37 | */ 38 | public function getStorage() 39 | { 40 | return $this->storage; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Strategy/DefaultNamingStrategy.php: -------------------------------------------------------------------------------- 1 | load(__DIR__.'/config/config_'.$this->getEnvironment().'.yml'); 27 | } 28 | 29 | /** 30 | * @return string 31 | */ 32 | public function getCacheDir() 33 | { 34 | return sys_get_temp_dir().'/SRIORestUploadBundle/cache'; 35 | } 36 | 37 | /** 38 | * @return string 39 | */ 40 | public function getLogDir() 41 | { 42 | return sys_get_temp_dir().'/SRIORestUploadBundle/logs'; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/Fixtures/App/app/config/config.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: parameters.yml } 3 | 4 | framework: 5 | secret: MySecret 6 | templating: { engines: ['twig'] } 7 | router: { resource: "%kernel.root_dir%/config/routing.yml" } 8 | form: true 9 | csrf_protection: true 10 | session: ~ 11 | default_locale: en 12 | translator: { fallback: en } 13 | profiler: { only_exceptions: false } 14 | 15 | # Doctrine Configuration 16 | doctrine: 17 | dbal: 18 | driver: %database_driver% 19 | host: %database_host% 20 | port: %database_port% 21 | dbname: %database_name% 22 | user: %database_user% 23 | password: %database_password% 24 | charset: UTF8 25 | 26 | orm: 27 | auto_generate_proxy_classes: %kernel.debug% 28 | auto_mapping: true 29 | mappings: 30 | RestUploadBundleTest: 31 | type: annotation 32 | is_bundle: false 33 | dir: %kernel.root_dir%/../../Entity 34 | prefix: SRIO\RestUploadBundle\Tests\Fixtures\Entity 35 | alias: RestUploadBundleTest 36 | 37 | SRIORestUploadBundle: 38 | type: xml 39 | is_bundle: true 40 | 41 | # enable the web profiler 42 | web_profiler: 43 | toolbar: false 44 | intercept_redirects: false 45 | 46 | # Gaufrette configuration 47 | knp_gaufrette: 48 | adapters: 49 | test: 50 | local: 51 | directory: %kernel.root_dir%/../web/uploads 52 | filesystems: 53 | test: 54 | adapter: test 55 | 56 | oneup_flysystem: 57 | adapters: 58 | test: 59 | local: 60 | directory: %kernel.root_dir%/../web/uploads 61 | filesystems: 62 | test: 63 | adapter: test 64 | 65 | # RestUploadBundle configuration 66 | srio_rest_upload: 67 | upload_type_parameter: uploadType 68 | resumable_entity_class: SRIO\RestUploadBundle\Tests\Fixtures\Entity\ResumableUploadSession 69 | -------------------------------------------------------------------------------- /Tests/Fixtures/App/app/config/config_flysystem.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: config.yml } 3 | 4 | framework: 5 | test: ~ 6 | session: 7 | storage_id: session.storage.filesystem 8 | 9 | srio_rest_upload: 10 | storages: 11 | default: 12 | type: flysystem 13 | filesystem: oneup_flysystem.test_filesystem -------------------------------------------------------------------------------- /Tests/Fixtures/App/app/config/config_gaufrette.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: config.yml } 3 | 4 | framework: 5 | test: ~ 6 | session: 7 | storage_id: session.storage.filesystem 8 | 9 | srio_rest_upload: 10 | storages: 11 | default: 12 | type: gaufrette 13 | filesystem: gaufrette.test_filesystem -------------------------------------------------------------------------------- /Tests/Fixtures/App/app/config/config_test.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: config.yml } 3 | 4 | framework: 5 | test: ~ 6 | session: 7 | storage_id: session.storage.filesystem 8 | -------------------------------------------------------------------------------- /Tests/Fixtures/App/app/config/parameters.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | database_driver: pdo_mysql 3 | database_host: 127.0.0.1 4 | database_port: ~ 5 | database_name: restuploadbundle 6 | database_user: root 7 | database_password: ~ 8 | 9 | mailer_transport: smtp 10 | mailer_host: 127.0.0.1 11 | mailer_user: ~ 12 | mailer_password: ~ 13 | 14 | locale: en 15 | secret: ThisTokenIsNotSoSecretChangeIt 16 | -------------------------------------------------------------------------------- /Tests/Fixtures/App/app/config/parameters.yml.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | database_driver: pdo_mysql 3 | database_host: 127.0.0.1 4 | database_port: ~ 5 | database_name: restuploadbundle 6 | database_user: ~ 7 | database_password: ~ 8 | 9 | mailer_transport: smtp 10 | mailer_host: 127.0.0.1 11 | mailer_user: ~ 12 | mailer_password: ~ 13 | 14 | locale: en 15 | secret: ThisTokenIsNotSoSecretChangeIt 16 | -------------------------------------------------------------------------------- /Tests/Fixtures/App/app/config/routing.yml: -------------------------------------------------------------------------------- 1 | srio_rest_upload_test_bundle: 2 | resource: "@SRIORestUploadBundle/Tests/Fixtures/Controller/" 3 | type: annotation 4 | prefix: / 5 | -------------------------------------------------------------------------------- /Tests/Fixtures/App/app/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(array('--env', '-e'), getenv('SYMFONY_ENV') ?: 'dev'); 19 | $debug = getenv('SYMFONY_DEBUG') !== '0' && !$input->hasParameterOption(array('--no-debug', '')) && $env !== 'prod'; 20 | 21 | if ($debug) { 22 | Debug::enable(); 23 | } 24 | 25 | $kernel = new AppKernel($env, $debug); 26 | $application = new Application($kernel); 27 | $application->run($input); 28 | -------------------------------------------------------------------------------- /Tests/Fixtures/App/web/.gitignore: -------------------------------------------------------------------------------- 1 | /uploads -------------------------------------------------------------------------------- /Tests/Fixtures/App/web/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sroze/SRIORestUploadBundle/ba3e25acd038f51b886f6587629596037c931119/Tests/Fixtures/App/web/uploads/.gitkeep -------------------------------------------------------------------------------- /Tests/Fixtures/Controller/UploadController.php: -------------------------------------------------------------------------------- 1 | createForm(new MediaFormType()); 33 | 34 | /** @var $uploadHandler UploadHandler */ 35 | $uploadHandler = $this->get('srio_rest_upload.upload_handler'); 36 | $result = $uploadHandler->handleRequest($request, $form); 37 | 38 | if (($response = $result->getResponse()) != null) { 39 | return $response; 40 | } 41 | 42 | if (!$form->isValid()) { 43 | throw new BadRequestHttpException(); 44 | } 45 | 46 | if (($file = $result->getFile()) !== null) { 47 | /** @var $media Media */ 48 | $media = $form->getData(); 49 | $media->setFile($file); 50 | 51 | $em = $this->getDoctrine()->getManager(); 52 | $em->persist($media); 53 | $em->flush(); 54 | 55 | return new JsonResponse($media); 56 | } 57 | 58 | throw new NotAcceptableHttpException(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/Fixtures/Entity/Media.php: -------------------------------------------------------------------------------- 1 | path = $uploaded->getFile()->getName(); 63 | $this->size = $uploaded->getFile()->getSize(); 64 | 65 | // TODO Add mimetype on `UploadedFile` 66 | $this->mimeType = $uploaded->getStorage()->getFilesystem()->getMimeType($this->path); 67 | 68 | // TODO Add original name 69 | $this->originalName = $uploaded->getFile()->getName(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/Fixtures/Entity/ResumableUploadSession.php: -------------------------------------------------------------------------------- 1 | add('name', 'text', array( 19 | 'required' => true, 20 | 'constraints' => array(new NotBlank()), 21 | )) 22 | ; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | public function setDefaultOptions(OptionsResolverInterface $resolver) 29 | { 30 | $resolver->setDefaults(array( 31 | 'csrf_protection' => false, 32 | 'data_class' => 'SRIO\RestUploadBundle\Tests\Fixtures\Entity\Media', 33 | )); 34 | } 35 | 36 | public function getName() 37 | { 38 | return; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/Fixtures/Resources/apple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sroze/SRIORestUploadBundle/ba3e25acd038f51b886f6587629596037c931119/Tests/Fixtures/Resources/apple.gif -------------------------------------------------------------------------------- /Tests/Fixtures/Resources/lorem.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce tristique felis tellus, ac fermentum turpis vulputate cursus. Quisque eget eros ac mi lobortis mollis in ut mauris. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec dignissim vulputate tortor ac lobortis. Cras ac ante ut sapien mollis porta eu vel leo. Aenean varius leo quis nulla vehicula, quis euismod diam interdum. Aenean et justo nec tortor volutpat volutpat. Curabitur et risus at mi rutrum posuere. Duis neque purus, condimentum quis dolor sed, tincidunt fringilla lorem. Proin gravida urna nec orci porttitor, sed condimentum arcu iaculis. Sed placerat eget sapien nec hendrerit. 2 | 3 | Nulla accumsan interdum nunc, at accumsan velit tristique ac. Vivamus vulputate convallis mi in tristique. Aliquam vehicula, purus at luctus dignissim, velit sem consectetur magna, a sagittis est nibh sed metus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In hac habitasse platea dictumst. Fusce accumsan tincidunt ullamcorper. Nulla facilisi. 4 | 5 | Maecenas non bibendum nunc. Vivamus in adipiscing lectus, sed sagittis mi. Sed ut odio ultricies, mattis tellus nec, consectetur eros. Morbi eu interdum risus. Duis pellentesque tortor erat, eu vulputate nulla consectetur non. Aenean fermentum imperdiet enim, pulvinar volutpat magna eleifend id. Etiam condimentum, ipsum vel mollis dictum, nibh diam dictum sapien, nec aliquam purus odio vitae nulla. Vestibulum rutrum ullamcorper velit id suscipit. In pretium vel sem in feugiat. Ut id rhoncus eros. 6 | 7 | In sit amet sagittis lacus. Integer quis mi lobortis, euismod purus ac, pharetra ligula. Fusce ut orci sodales, viverra diam in, mollis justo. Aliquam feugiat facilisis accumsan. Cras viverra a ante eget vestibulum. In hac habitasse platea dictumst. Nullam mattis eleifend diam et volutpat. Nulla sed condimentum augue. Nulla mollis condimentum dolor, ut scelerisque tellus blandit pellentesque. 8 | 9 | Ut egestas venenatis turpis quis ultrices. Integer blandit nisl ut nibh semper cursus. Nulla in ligula euismod, venenatis metus a, feugiat mi. Praesent interdum, risus condimentum fermentum tincidunt, neque nisl pulvinar nulla, ut dignissim purus neque quis lacus. Aenean eget sapien eu tortor convallis pretium. Etiam felis ligula, commodo vel lacus sit amet, auctor porttitor quam. Sed mollis enim in elit luctus sagittis. Pellentesque vestibulum adipiscing lacus, at venenatis ipsum dapibus vel. Etiam rhoncus massa mollis aliquam tincidunt. Quisque quam sapien, aliquet nec nibh eu, pretium tempor sapien. -------------------------------------------------------------------------------- /Tests/Processor/AbstractProcessorTestCase.php: -------------------------------------------------------------------------------- 1 | getMethod(get_class($object), $methodName); 21 | 22 | return $method->invokeArgs($object, $arguments); 23 | } 24 | 25 | /** 26 | * Get a protected method as public. 27 | * 28 | * @param $name 29 | * 30 | * @return \ReflectionMethod 31 | */ 32 | protected function getMethod($className, $name) 33 | { 34 | $class = new \ReflectionClass($className); 35 | 36 | $method = $class->getMethod($name); 37 | $method->setAccessible(true); 38 | 39 | return $method; 40 | } 41 | 42 | /** 43 | * @return \PHPUnit_Framework_MockObject_MockObject 44 | */ 45 | protected function getMockEntityManager() 46 | { 47 | return $this->getMock('\Doctrine\ORM\EntityManager', 48 | array('getRepository', 'getClassMetadata', 'persist', 'flush'), array(), '', false); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/Processor/MultipartUploadProcessorTest.php: -------------------------------------------------------------------------------- 1 | getNewClient(); 12 | $image = $this->getResource($client, 'apple.gif'); 13 | $data = array('test' => 'OK'); 14 | $jsonData = json_encode($data); 15 | 16 | $multipartUploadProcessor = $this->getProcessor(); 17 | $request = $this->createMultipartRequest($jsonData, $image); 18 | 19 | $partOne = $this->callMethod($multipartUploadProcessor, 'getPart', array($request, 1)); 20 | $this->assertTrue(is_array($partOne)); 21 | list($contentType, $body) = $partOne; 22 | 23 | $this->assertEquals('application/json; charset=UTF-8', $contentType); 24 | $this->assertEquals($jsonData, $body); 25 | 26 | $partTwo = $this->callMethod($multipartUploadProcessor, 'getPart', array($request, 2)); 27 | $this->assertTrue(is_array($partTwo)); 28 | list($contentType, $body) = $partTwo; 29 | 30 | $this->assertEquals('image/gif', $contentType); 31 | $this->assertEquals($image, $body); 32 | } 33 | 34 | public function testGetPartsResource() 35 | { 36 | $client = $this->getNewClient(); 37 | $image = $this->getResource($client, 'apple.gif'); 38 | $data = array('test' => 'OK'); 39 | $jsonData = json_encode($data); 40 | $boundary = uniqid(); 41 | $content = $this->createMultipartContent($boundary, $jsonData, $image); 42 | 43 | $tempFile = $this->getResourcePath($client, 'test.tmp'); 44 | file_put_contents($tempFile, $content); 45 | $resource = fopen($tempFile, 'r'); 46 | 47 | $multipartUploadProcessor = $this->getProcessor(); 48 | $request = $this->createMultipartRequestWithContent($boundary, $resource); 49 | 50 | $partOne = $this->callMethod($multipartUploadProcessor, 'getPart', array($request, 1)); 51 | $this->assertTrue(is_array($partOne)); 52 | list($contentType, $body) = $partOne; 53 | 54 | $this->assertEquals('application/json; charset=UTF-8', $contentType); 55 | $this->assertEquals($jsonData, $body); 56 | 57 | $partTwo = $this->callMethod($multipartUploadProcessor, 'getPart', array($request, 2)); 58 | $this->assertTrue(is_array($partTwo)); 59 | list($contentType, $body) = $partTwo; 60 | 61 | $this->assertEquals('image/gif', $contentType); 62 | $this->assertEquals($image, $body); 63 | 64 | // Clean up 65 | fclose($resource); 66 | unlink($tempFile); 67 | } 68 | 69 | protected function createMultipartRequest($jsonData, $binaryContent) 70 | { 71 | $boundary = uniqid(); 72 | $content = $this->createMultipartContent($boundary, $jsonData, $binaryContent); 73 | 74 | return $this->createMultipartRequestWithContent($boundary, $content); 75 | } 76 | 77 | protected function createMultipartContent($boundary, $jsonData, $binaryContent) 78 | { 79 | $content = '--'.$boundary."\r\n".'Content-Type: application/json; charset=UTF-8'."\r\n\r\n".$jsonData."\r\n\r\n"; 80 | $content .= '--'.$boundary."\r\n".'Content-Type: image/gif'."\r\n\r\n".$binaryContent."\r\n\r\n"; 81 | $content .= '--'.$boundary.'--'; 82 | 83 | return $content; 84 | } 85 | 86 | protected function createMultipartRequestWithContent($boundary, $content) 87 | { 88 | $request = $this->getMock('\Symfony\Component\HttpFoundation\Request'); 89 | $request->expects($this->any()) 90 | ->method('getContent') 91 | ->will($this->returnValue($content)); 92 | 93 | $request->headers = new HeaderBag(array( 94 | 'Content-Type' => 'multipart/related; boundary="'.$boundary.'"', 95 | )); 96 | 97 | return $request; 98 | } 99 | 100 | protected function getProcessor() 101 | { 102 | $voter = $this->getMock( 103 | 'SRIO\RestUploadBundle\Voter\StorageVoter' 104 | ); 105 | 106 | $storageHandler = $this->getMock( 107 | '\SRIO\RestUploadBundle\Upload\StorageHandler', 108 | array(), 109 | array($voter) 110 | ); 111 | 112 | $processor = $this->getMock( 113 | '\SRIO\RestUploadBundle\Processor\MultipartUploadProcessor', 114 | array(), 115 | array($storageHandler) 116 | ); 117 | 118 | return $processor; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Tests/Processor/ResumableUploadProcessorTest.php: -------------------------------------------------------------------------------- 1 | callParseContentRange($string); 15 | $this->assertTrue(is_array($result)); 16 | $this->assertEquals($start, $result['start']); 17 | $this->assertEquals($end, $result['end']); 18 | $this->assertEquals($length, $result['total']); 19 | } 20 | 21 | /** 22 | * @dataProvider contentErrorRangeDataProvider 23 | * @expectedException \SRIO\RestUploadBundle\Exception\UploadProcessorException 24 | * 25 | * @param $string 26 | */ 27 | public function testErrorComputeContentRange($string) 28 | { 29 | $this->callParseContentRange($string); 30 | } 31 | 32 | /** 33 | * Call parseContentRange function. 34 | * 35 | * @param $string 36 | * 37 | * @return mixed 38 | */ 39 | protected function callParseContentRange($string) 40 | { 41 | $voter = $this->getMock( 42 | 'SRIO\RestUploadBundle\Voter\StorageVoter' 43 | ); 44 | 45 | $storageHandler = $this->getMock( 46 | '\SRIO\RestUploadBundle\Upload\StorageHandler', 47 | array(), 48 | array($voter) 49 | ); 50 | 51 | $method = $this->getMethod('\SRIO\RestUploadBundle\Processor\ResumableUploadProcessor', 'parseContentRange'); 52 | $em = $this->getMockEntityManager(); 53 | $uploadProcessor = new ResumableUploadProcessor($storageHandler, $em, 'SRIO\RestUploadBundle\Tests\Fixtures\Entity\ResumableUploadSession'); 54 | 55 | return $method->invokeArgs($uploadProcessor, array($string)); 56 | } 57 | 58 | /** 59 | * Data Provider for success Content-Range test. 60 | */ 61 | public function contentSuccessRangeDataProvider() 62 | { 63 | return array( 64 | array('bytes 1-2/12', 1, 2, 12), 65 | array('bytes */1000', '*', null, 1000), 66 | array('bytes 0-1000/1000', 0, 1000, 1000), 67 | ); 68 | } 69 | 70 | /** 71 | * Data Provider for error Content-Range test. 72 | */ 73 | public function contentErrorRangeDataProvider() 74 | { 75 | return array( 76 | array('bytes 2-1/12'), 77 | array('bytes 12/12'), 78 | array('bytes 0-13/12'), 79 | array('1-2/12'), 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tests/Request/RequestContentHandlerTest.php: -------------------------------------------------------------------------------- 1 | getNewClient(); 13 | $filePath = $this->getResourcePath($client, 'apple.gif'); 14 | $content = file_get_contents($filePath); 15 | 16 | $this->doTest($content, $content); 17 | } 18 | 19 | public function testBinaryResourceContent() 20 | { 21 | $client = $this->getNewClient(); 22 | $filePath = $this->getResourcePath($client, 'apple.gif'); 23 | $content = fopen($filePath, 'r'); 24 | $expectedContent = file_get_contents($filePath); 25 | 26 | $this->doTest($expectedContent, $content); 27 | } 28 | 29 | public function testStringContent() 30 | { 31 | $client = $this->getNewClient(); 32 | $filePath = $this->getResourcePath($client, 'lorem.txt'); 33 | $content = file_get_contents($filePath); 34 | 35 | $this->doTest($content, $content); 36 | } 37 | 38 | public function testStringResourceContent() 39 | { 40 | $client = $this->getNewClient(); 41 | $filePath = $this->getResourcePath($client, 'lorem.txt'); 42 | $content = fopen($filePath, 'r'); 43 | $expectedContent = file_get_contents($filePath); 44 | 45 | $this->doTest($expectedContent, $content); 46 | } 47 | 48 | protected function doTest($expectedContent, $content) 49 | { 50 | $request = $this->getMock('\Symfony\Component\HttpFoundation\Request'); 51 | $request->expects($this->any()) 52 | ->method('getContent') 53 | ->will($this->returnValue($content)); 54 | 55 | $handler = new RequestContentHandler($request); 56 | $this->assertFalse($handler->eof()); 57 | 58 | $foundContent = ''; 59 | while (!$handler->eof()) { 60 | $foundContent .= $handler->gets(); 61 | } 62 | 63 | $this->assertEquals($expectedContent, $foundContent); 64 | $this->assertTrue($handler->eof()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/Upload/AbstractUploadTestCase.php: -------------------------------------------------------------------------------- 1 | getResponse(); 18 | $this->assertEquals(400, $response->getStatusCode()); 19 | } 20 | 21 | /** 22 | * Get content of a resource. 23 | * 24 | * @param Client $client 25 | * @param $name 26 | * 27 | * @return string 28 | * 29 | * @throws \RuntimeException 30 | */ 31 | protected function getResource(Client $client, $name) 32 | { 33 | $filePath = $this->getResourcePath($client, $name); 34 | if (!file_exists($filePath)) { 35 | throw new \RuntimeException(sprintf( 36 | 'File %s do not exists', 37 | $filePath 38 | )); 39 | } 40 | 41 | return file_get_contents($filePath); 42 | } 43 | 44 | /** 45 | * Get uploaded file path. 46 | * 47 | * @param Client $client 48 | * @param $name 49 | * 50 | * @return string 51 | */ 52 | protected function getUploadedFilePath(Client $client) 53 | { 54 | return $client->getContainer()->getParameter('kernel.root_dir').'/../web/uploads'; 55 | } 56 | 57 | /** 58 | * Get resource path. 59 | * 60 | * @param Client $client 61 | * @param $name 62 | * 63 | * @return string 64 | */ 65 | protected function getResourcePath(Client $client, $name) 66 | { 67 | return $client->getContainer()->getParameter('kernel.root_dir').'/../../Resources/'.$name; 68 | } 69 | 70 | /** 71 | * Creates a Client. 72 | * 73 | * @param array $options An array of options to pass to the createKernel class 74 | * @param array $server An array of server parameters 75 | * 76 | * @return Client A Client instance 77 | */ 78 | protected function getNewClient(array $options = array(), array $server = array()) 79 | { 80 | $options = array_merge(array('environment' => isset($_SERVER['TEST_FILESYSTEM']) ? strtolower($_SERVER['TEST_FILESYSTEM']) : 'gaufrette'), $options); 81 | 82 | return static::createClient($options, $server); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Tests/Upload/MultipartUploadTest.php: -------------------------------------------------------------------------------- 1 | getNewClient(); 10 | $queryParameters = array('name' => 'test'); 11 | 12 | $boundary = uniqid(); 13 | $content = '--'.$boundary."\r\n".'Content-Type: application/json; charset=UTF-8'."\r\n\r\n".json_encode($queryParameters)."\r\n\r\n"; 14 | $content .= '--'.$boundary.'--'; 15 | 16 | $client->request('POST', '/upload?uploadType=multipart', array(), array(), array( 17 | 'CONTENT_TYPE' => 'multipart/related; boundary="'.$boundary.'"', 18 | 'CONTENT_LENGTH' => strlen($content), 19 | ), $content); 20 | $this->assertResponseHasErrors($client); 21 | } 22 | 23 | public function testWithoutHeaders() 24 | { 25 | $client = $this->getNewClient(); 26 | $queryParameters = array('name' => 'test'); 27 | 28 | $boundary = uniqid(); 29 | $image = $this->getResource($client, 'apple.gif'); 30 | $content = '--'.$boundary."\r\n".'Content-Type: image/gif'."\r\n\r\n".$image."\r\n\r\n"; 31 | $content .= '--'.$boundary."\r\n".'Content-Type: application/json; charset=UTF-8'."\r\n\r\n".json_encode($queryParameters)."\r\n\r\n"; 32 | $content .= '--'.$boundary.'--'; 33 | 34 | $client->request('POST', '/upload?uploadType=multipart', array(), array(), array(), $content); 35 | $this->assertResponseHasErrors($client); 36 | } 37 | 38 | public function testWithoutBoundary() 39 | { 40 | $client = $this->getNewClient(); 41 | $queryParameters = array('name' => 'test'); 42 | 43 | $boundary = uniqid(); 44 | $image = $this->getResource($client, 'apple.gif'); 45 | $content = '--'.$boundary."\r\n".'Content-Type: image/gif'."\r\n\r\n".$image."\r\n\r\n"; 46 | $content .= '--'.$boundary."\r\n".'Content-Type: application/json; charset=UTF-8'."\r\n\r\n".json_encode($queryParameters)."\r\n\r\n"; 47 | $content .= '--'.$boundary.'--'; 48 | 49 | $client->request('POST', '/upload?uploadType=multipart', array(), array(), array( 50 | 'CONTENT_TYPE' => 'multipart/related', 51 | 'CONTENT_LENGTH' => strlen($content), 52 | ), $content); 53 | $this->assertResponseHasErrors($client); 54 | } 55 | 56 | public function testBinaryBeforeMeta() 57 | { 58 | $client = $this->getNewClient(); 59 | $queryParameters = array('name' => 'test'); 60 | 61 | $boundary = uniqid(); 62 | $image = $this->getResource($client, 'apple.gif'); 63 | $content = '--'.$boundary."\r\n".'Content-Type: image/gif'."\r\n\r\n".$image."\r\n\r\n"; 64 | $content .= '--'.$boundary."\r\n".'Content-Type: application/json; charset=UTF-8'."\r\n\r\n".json_encode($queryParameters)."\r\n\r\n"; 65 | $content .= '--'.$boundary.'--'; 66 | 67 | $client->request('POST', '/upload?uploadType=multipart', array(), array(), array( 68 | 'CONTENT_TYPE' => 'multipart/related; boundary="'.$boundary.'"', 69 | 'CONTENT_LENGTH' => strlen($content), 70 | ), $content); 71 | $this->assertResponseHasErrors($client); 72 | } 73 | 74 | public function testMultipartUpload() 75 | { 76 | $client = $this->getNewClient(); 77 | $queryParameters = array('name' => 'test'); 78 | 79 | $boundary = uniqid(); 80 | $image = $this->getResource($client, 'apple.gif'); 81 | $content = '--'.$boundary."\r\n".'Content-Type: application/json; charset=UTF-8'."\r\n\r\n".json_encode($queryParameters)."\r\n\r\n"; 82 | $content .= '--'.$boundary."\r\n".'Content-Type: image/gif'."\r\n\r\n".$image."\r\n\r\n"; 83 | $content .= '--'.$boundary.'--'; 84 | 85 | $client->request('POST', '/upload?uploadType=multipart', array(), array(), array( 86 | 'CONTENT_TYPE' => 'multipart/related; boundary="'.$boundary.'"', 87 | 'CONTENT_LENGTH' => strlen($content), 88 | ), $content); 89 | 90 | $response = $client->getResponse(); 91 | $this->assertEquals(200, $response->getStatusCode()); 92 | $jsonContent = json_decode($response->getContent(), true); 93 | $this->assertNotEmpty($jsonContent); 94 | $this->assertFalse(array_key_exists('errors', $jsonContent)); 95 | $this->assertTrue(array_key_exists('path', $jsonContent)); 96 | $this->assertTrue(array_key_exists('size', $jsonContent)); 97 | $this->assertTrue(array_key_exists('name', $jsonContent)); 98 | $this->assertEquals('test', $jsonContent['name']); 99 | $this->assertEquals(strlen($image), $jsonContent['size']); 100 | 101 | $filePath = $this->getUploadedFilePath($client).$jsonContent['path']; 102 | $this->assertTrue(file_exists($filePath)); 103 | $this->assertEquals($image, file_get_contents($filePath)); 104 | $this->assertTrue(array_key_exists('id', $jsonContent)); 105 | $this->assertNotEmpty($jsonContent['id']); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Tests/Upload/ResumableUploadTest.php: -------------------------------------------------------------------------------- 1 | startSession(); 12 | 13 | $response = $client->getResponse(); 14 | $location = $response->headers->get('Location'); 15 | $content = $this->getResource($client, 'apple.gif'); 16 | $client->request('PUT', $location, array(), array(), array( 17 | 'CONTENT_TYPE' => 'image/gif', 18 | 'CONTENT_LENGTH' => strlen($content), 19 | ), $content); 20 | 21 | $this->assertSuccessful($client, $content); 22 | } 23 | 24 | public function testChunkedUpload() 25 | { 26 | $client = $this->startSession(); 27 | 28 | $response = $client->getResponse(); 29 | $location = $response->headers->get('Location'); 30 | $content = $this->getResource($client, 'apple.gif'); 31 | $chunkSize = 256; 32 | 33 | for ($start = 0; $start < strlen($content); $start += $chunkSize) { 34 | $part = substr($content, $start, $chunkSize); 35 | $end = $start + strlen($part) - 1; 36 | $client->request('PUT', $location, array(), array(), array( 37 | 'CONTENT_TYPE' => 'image/gif', 38 | 'CONTENT_LENGTH' => strlen($part), 39 | 'HTTP_Content-Range' => 'bytes '.$start.'-'.$end.'/'.strlen($content), 40 | ), $part); 41 | 42 | $response = $client->getResponse(); 43 | if (($start + $chunkSize) < strlen($content)) { 44 | $this->assertEquals(308, $response->getStatusCode()); 45 | $this->assertEquals('0-'.$end, $response->headers->get('Range')); 46 | 47 | $client->request('PUT', $location, array(), array(), array( 48 | 'CONTENT_LENGTH' => 0, 49 | 'HTTP_Content-Range' => 'bytes */'.strlen($content), 50 | )); 51 | 52 | $response = $client->getResponse(); 53 | $this->assertEquals(308, $response->getStatusCode()); 54 | $this->assertEquals('0-'.$end, $response->headers->get('Range')); 55 | } 56 | } 57 | 58 | $this->assertSuccessful($client, $content); 59 | } 60 | 61 | protected function startSession() 62 | { 63 | $client = $this->getNewClient(); 64 | $content = $this->getResource($client, 'apple.gif'); 65 | $parameters = array('name' => 'test'); 66 | $json = json_encode($parameters); 67 | 68 | $client->request('POST', '/upload?uploadType=resumable', array(), array(), array( 69 | 'CONTENT_TYPE' => 'application/json', 70 | 'CONTENT_LENGTH' => strlen($json), 71 | 'HTTP_X-Upload-Content-Type' => 'image/gif', 72 | 'HTTP_X-Upload-Content-Length' => strlen($content), 73 | ), $json); 74 | 75 | $response = $client->getResponse(); 76 | $this->assertEquals(200, $response->getStatusCode()); 77 | $this->assertTrue($response->headers->has('Location')); 78 | $this->assertEquals(0, $response->headers->get('Content-Length', 0)); 79 | 80 | return $client; 81 | } 82 | 83 | protected function assertSuccessful(Client $client, $content) 84 | { 85 | $response = $client->getResponse(); 86 | $this->assertEquals(200, $response->getStatusCode()); 87 | $jsonContent = json_decode($response->getContent(), true); 88 | $this->assertNotEmpty($jsonContent); 89 | $this->assertTrue(array_key_exists('path', $jsonContent)); 90 | $this->assertTrue(array_key_exists('size', $jsonContent)); 91 | $this->assertTrue(array_key_exists('name', $jsonContent)); 92 | $this->assertEquals('test', $jsonContent['name']); 93 | $this->assertEquals(strlen($content), $jsonContent['size']); 94 | 95 | $filePath = $this->getUploadedFilePath($client).$jsonContent['path']; 96 | $this->assertTrue(file_exists($filePath)); 97 | $this->assertEquals($content, file_get_contents($filePath)); 98 | $this->assertTrue(array_key_exists('id', $jsonContent)); 99 | $this->assertNotEmpty($jsonContent['id']); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Tests/Upload/SimpleUploadTest.php: -------------------------------------------------------------------------------- 1 | getNewClient(); 10 | $queryParameters = array('uploadType' => 'simple', 'name' => 'test'); 11 | 12 | $content = $this->getResource($client, 'apple.gif'); 13 | 14 | // Set empty content type header since Request defaults to application/x-www-form-urlencoded 15 | $client->request('POST', '/upload?'.http_build_query($queryParameters), array(), array(), array('CONTENT_TYPE' => ''), $content); 16 | 17 | $response = $client->getResponse(); 18 | $this->assertEquals(400, $response->getStatusCode()); 19 | } 20 | 21 | public function testWithoutFormSimpleUpload() 22 | { 23 | $client = $this->getNewClient(); 24 | $queryParameters = array('uploadType' => 'simple'); 25 | $content = $this->getResource($client, 'apple.gif'); 26 | $client->request('POST', '/upload?'.http_build_query($queryParameters), array(), array(), array( 27 | 'CONTENT_TYPE' => 'image/gif', 28 | 'CONTENT_LENGTH' => strlen($content), 29 | )); 30 | 31 | $response = $client->getResponse(); 32 | $this->assertEquals(400, $response->getStatusCode()); 33 | } 34 | 35 | public function testWithoutContentSimpleUpload() 36 | { 37 | $client = $this->getNewClient(); 38 | $queryParameters = array('uploadType' => 'simple', 'name' => 'test'); 39 | $client->request('POST', '/upload?'.http_build_query($queryParameters)); 40 | 41 | $response = $client->getResponse(); 42 | $this->assertEquals(400, $response->getStatusCode()); 43 | } 44 | 45 | public function testSimpleUpload() 46 | { 47 | $client = $this->getNewClient(); 48 | $queryParameters = array('uploadType' => 'simple', 'name' => 'test'); 49 | 50 | $content = $this->getResource($client, 'apple.gif'); 51 | $client->request('POST', '/upload?'.http_build_query($queryParameters), array(), array(), array( 52 | 'CONTENT_TYPE' => 'image/gif', 53 | 'CONTENT_LENGTH' => strlen($content), 54 | ), $content); 55 | 56 | $response = $client->getResponse(); 57 | $this->assertEquals(200, $response->getStatusCode()); 58 | $jsonContent = json_decode($response->getContent(), true); 59 | $this->assertNotEmpty($jsonContent); 60 | $this->assertTrue(array_key_exists('path', $jsonContent)); 61 | $this->assertTrue(array_key_exists('size', $jsonContent)); 62 | $this->assertTrue(array_key_exists('name', $jsonContent)); 63 | $this->assertEquals('test', $jsonContent['name']); 64 | $this->assertEquals(strlen($content), $jsonContent['size']); 65 | 66 | $localPath = $this->getUploadedFilePath($client).$jsonContent['path']; 67 | $this->assertTrue(file_exists($localPath)); 68 | $this->assertEquals($content, file_get_contents($localPath)); 69 | $this->assertTrue(array_key_exists('id', $jsonContent)); 70 | $this->assertNotEmpty($jsonContent['id']); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | voter = $voter; 29 | } 30 | 31 | /** 32 | * Store a file's content. 33 | * 34 | * @param UploadContext $context 35 | * @param string $content 36 | * @param array $config 37 | * @param bool $overwrite 38 | * 39 | * @return UploadedFile 40 | */ 41 | public function store(UploadContext $context, $contents, array $config = array(), $overwrite = false) 42 | { 43 | return $this->getStorage($context)->store($context, $contents, $config, $overwrite); 44 | } 45 | 46 | /** 47 | * Store a file's content. 48 | * 49 | * @param UploadContext $context 50 | * @param resource $resource 51 | * @param array $config 52 | * @param bool $overwrite 53 | * 54 | * @return UploadedFile 55 | */ 56 | public function storeStream(UploadContext $context, $resource, array $config = array(), $overwrite = false) 57 | { 58 | return $this->getStorage($context)->storeStream($context, $resource, $config, $overwrite); 59 | } 60 | 61 | /** 62 | * @return FilesystemAdapterInterface 63 | */ 64 | public function getFilesystem(UploadContext $context) 65 | { 66 | return $this->getStorage($context)->getFilesystem(); 67 | } 68 | 69 | /** 70 | * Get storage by upload context. 71 | * 72 | * @param UploadContext $context 73 | * 74 | * @return FileStorage 75 | * 76 | * @throws \SRIO\RestUploadBundle\Exception\UploadException 77 | */ 78 | public function getStorage(UploadContext $context) 79 | { 80 | $storage = $this->voter->getStorage($context); 81 | if (!$storage instanceof FileStorage) { 82 | throw new UploadException('Storage returned by voter isn\'t instanceof FileStorage'); 83 | } 84 | 85 | return $storage; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Upload/UploadContext.php: -------------------------------------------------------------------------------- 1 | request = $request; 45 | $this->form = $form; 46 | $this->config = $config; 47 | } 48 | 49 | /** 50 | * @param array $config 51 | */ 52 | public function setConfig($config) 53 | { 54 | $this->config = $config; 55 | } 56 | 57 | /** 58 | * @return array 59 | */ 60 | public function getConfig() 61 | { 62 | return $this->config; 63 | } 64 | 65 | /** 66 | * @param \SRIO\RestUploadBundle\Storage\UploadedFile $file 67 | */ 68 | public function setFile($file) 69 | { 70 | $this->file = $file; 71 | } 72 | 73 | /** 74 | * @return \SRIO\RestUploadBundle\Storage\UploadedFile 75 | */ 76 | public function getFile() 77 | { 78 | return $this->file; 79 | } 80 | 81 | /** 82 | * @param \Symfony\Component\Form\FormInterface $form 83 | */ 84 | public function setForm($form) 85 | { 86 | $this->form = $form; 87 | } 88 | 89 | /** 90 | * @return \Symfony\Component\Form\FormInterface 91 | */ 92 | public function getForm() 93 | { 94 | return $this->form; 95 | } 96 | 97 | /** 98 | * @param \Symfony\Component\HttpFoundation\Request $request 99 | */ 100 | public function setRequest($request) 101 | { 102 | $this->request = $request; 103 | } 104 | 105 | /** 106 | * @return \Symfony\Component\HttpFoundation\Request 107 | */ 108 | public function getRequest() 109 | { 110 | return $this->request; 111 | } 112 | 113 | /** 114 | * @param string $storageName 115 | */ 116 | public function setStorageName($storageName) 117 | { 118 | $this->storageName = $storageName; 119 | } 120 | 121 | /** 122 | * @return string 123 | */ 124 | public function getStorageName() 125 | { 126 | return $this->storageName; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Upload/UploadHandler.php: -------------------------------------------------------------------------------- 1 | uploadTypeParameter = $uploadTypeParameter; 32 | } 33 | 34 | /** 35 | * Add an upload processor. 36 | * 37 | * @param $uploadType 38 | * @param ProcessorInterface $processor 39 | * 40 | * @throws \LogicException 41 | */ 42 | public function addProcessor($uploadType, ProcessorInterface $processor) 43 | { 44 | if (array_key_exists($uploadType, $this->processors)) { 45 | throw new \LogicException(sprintf( 46 | 'A processor is already registered for type %s', 47 | $uploadType 48 | )); 49 | } 50 | 51 | $this->processors[$uploadType] = $processor; 52 | } 53 | 54 | /** 55 | * Handle the upload request. 56 | * 57 | * @param Request $request 58 | * @param \Symfony\Component\Form\FormInterface $form 59 | * @param array $config 60 | * 61 | * @throws \SRIO\RestUploadBundle\Exception\UploadException 62 | * 63 | * @return UploadResult 64 | */ 65 | public function handleRequest(Request $request, FormInterface $form = null, array $config = array()) 66 | { 67 | try { 68 | $processor = $this->getProcessor($request, $config); 69 | 70 | return $processor->handleUpload($request, $form, $config); 71 | } catch (UploadException $e) { 72 | if ($form != null) { 73 | $form->addError(new FormError($e->getMessage())); 74 | } 75 | 76 | $result = new UploadResult(); 77 | $result->setException($e); 78 | $result->setForm($form); 79 | 80 | return $result; 81 | } 82 | } 83 | 84 | /** 85 | * Get the upload processor. 86 | * 87 | * @param \Symfony\Component\HttpFoundation\Request $request 88 | * @param array $config 89 | * 90 | * @throws \SRIO\RestUploadBundle\Exception\UploadProcessorException 91 | * 92 | * @return ProcessorInterface 93 | */ 94 | protected function getProcessor(Request $request, array $config) 95 | { 96 | $uploadType = $request->get($this->getUploadTypeParameter($config)); 97 | 98 | if (!array_key_exists($uploadType, $this->processors)) { 99 | throw new UploadProcessorException(sprintf( 100 | 'Unknown upload processor for upload type %s', 101 | $uploadType 102 | )); 103 | } 104 | 105 | return $this->processors[$uploadType]; 106 | } 107 | 108 | /** 109 | * Get the current upload type parameter. 110 | * 111 | * @param array $extraConfiguration 112 | * 113 | * @internal param $parameter 114 | * @internal param $config 115 | * 116 | * @return mixed 117 | */ 118 | protected function getUploadTypeParameter(array $extraConfiguration) 119 | { 120 | return array_key_exists('uploadTypeParameter', $extraConfiguration) 121 | ? $extraConfiguration['uploadTypeParameter'] 122 | : $this->uploadTypeParameter; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Upload/UploadResult.php: -------------------------------------------------------------------------------- 1 | exception = $exception; 23 | } 24 | 25 | /** 26 | * @return \SRIO\RestUploadBundle\Exception\UploadException 27 | */ 28 | public function getException() 29 | { 30 | return $this->exception; 31 | } 32 | 33 | /** 34 | * @param \Symfony\Component\HttpFoundation\Response $response 35 | */ 36 | public function setResponse($response) 37 | { 38 | $this->response = $response; 39 | } 40 | 41 | /** 42 | * @return \Symfony\Component\HttpFoundation\Response 43 | */ 44 | public function getResponse() 45 | { 46 | return $this->response; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Voter/StorageVoter.php: -------------------------------------------------------------------------------- 1 | defaultStorage = $defaultStorage; 34 | } 35 | 36 | /** 37 | * Add a storage. 38 | * 39 | * @param FileStorage $storage 40 | * 41 | * @throws \RuntimeException 42 | */ 43 | public function addStorage(FileStorage $storage) 44 | { 45 | if (array_key_exists($storage->getName(), $this->storages)) { 46 | throw new \RuntimeException(sprintf( 47 | 'Storage with name %s already exists', 48 | $storage->getName() 49 | )); 50 | } 51 | 52 | $this->storages[$storage->getName()] = $storage; 53 | } 54 | 55 | /** 56 | * Get the best storage based on request and/or parameters. 57 | * 58 | * @param UploadContext $context 59 | * 60 | * @throws \SRIO\RestUploadBundle\Exception\UploadException 61 | * @throws \RuntimeException 62 | * 63 | * @return FileStorage 64 | */ 65 | public function getStorage(UploadContext $context) 66 | { 67 | if (count($this->storages) == 0) { 68 | throw new UploadException('No storage found'); 69 | } 70 | 71 | if (($storageName = $context->getStorageName()) !== null 72 | || (($storageName = $this->defaultStorage) !== null)) { 73 | if (!array_key_exists($storageName, $this->storages)) { 74 | throw new \RuntimeException(sprintf( 75 | 'Storage with name %s do not exists', 76 | $storageName 77 | )); 78 | } 79 | 80 | return $this->storages[$storageName]; 81 | } 82 | 83 | return current($this->storages); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "srio/rest-upload-bundle", 3 | "description": "Handle multiple rest upload ways", 4 | "keywords": ["symfony", "file", "upload", "api", "rest", "multipart", "resumable", "form-data"], 5 | "type": "symfony-bundle", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Samuel ROZE", 10 | "email": "samuel@sroze.io", 11 | "homepage": "http://www.sroze.io" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=5.3.3", 16 | "symfony/symfony": ">=2.3.0", 17 | "incenteev/composer-parameter-handler": "~2.0" 18 | }, 19 | "require-dev": { 20 | "sensio/framework-extra-bundle": ">=2.3.0", 21 | "doctrine/orm": "~2.2,>=2.2.3", 22 | "doctrine/doctrine-bundle": ">=1.2.0", 23 | "phpunit/phpunit": "3.7.*", 24 | "knplabs/knp-gaufrette-bundle": ">=0.1.7", 25 | "oneup/flysystem-bundle": "~1.4" 26 | }, 27 | "suggest": { 28 | "doctrine/orm": ">=2.2.3,<2.4-dev", 29 | "doctrine/doctrine-bundle": ">=1.2.0", 30 | "knplabs/knp-gaufrette-bundle": "Required if you use the knplabs/gaufrette filesystem abstraction layer", 31 | "oneup/flysystem-bundle": "Required if you use the league/flysystem filesystem abstraction layer" 32 | }, 33 | "autoload": { 34 | "psr-0": { "SRIO\\RestUploadBundle": "" } 35 | }, 36 | "scripts": { 37 | "test": "./test.sh", 38 | "contrib": [ 39 | "php-cs-fixer fix .", 40 | "./test.sh" 41 | ] 42 | }, 43 | "target-dir": "SRIO/RestUploadBundle" 44 | } 45 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | ./Tests/ 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ./ 25 | 26 | ./Resources 27 | ./Tests 28 | ./vendor 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export TEST_FILESYSTEM="Gaufrette" 4 | vendor/bin/phpunit 5 | 6 | export TEST_FILESYSTEM="Flysystem" 7 | vendor/bin/phpunit --------------------------------------------------------------------------------