[WIP] Add Response::withFile() method#88
Conversation
|
I wonder if we should split it into two methods. Also, I think we should take in a |
|
What is the difference between file download and file stream? A What about the other configs (ETag, Last-Modified)? Should that be left to the user? I suggest to add these as optional params (boolean) to the method(s). Do you think that the content type(s) should automatically be detected? What about other headers like I have found a lot of information about the filename part of the It is possible not to give a filename at all. For example simply Content-Disposition: attachmentThen the browser would use the last part of the path of the url. So we need to cover that possibility. I suggest, that if the filename is an empty string, then the filename would not be attached to the A lot of interesting tests could be found at http://test.greenbytes.de/tech/tc2231/. For example the use of non US-ASCII chars http://test.greenbytes.de/tech/tc2231/#attwithfn2231utf8comp. |
The file download would trigger en client side to download the contents while the stream would make the client read the contents, e.g. viewing a PDF versus downloading it.
I do think that should be optional for sure. and yes we can add it as a boolean.
This is possible via $finfo = new \finfo(FILEINFO_MIME_TYPE);
$contentType = $finfo->buffer($this->body->getContents());We should probably have a fallback in case we cannot detect or an optional user parameter perhaps?
I think that we might need to create a Here is the FileInterface <?php
use Psr\Http\Message\StreamInterface;
use RuntimeException;
interface FileInterface
{
/**
* @return string
*/
public function getFileName(): string;
/**
* @return string|null
*/
public function getPath(): ?string;
/**
* @return string|null
*/
public function getContentType(): ?string;
/**
* @return string
*/
public function getContents(): string;
}Here is the implementation <?php
use Psr\Http\Message\StreamInterface;
use RuntimeException;
class File implements FileInterface
{
/**
* @var string
*/
private $fileName;
/**
* @var string|null
*/
private $path;
/**
* @var string|null
*/
private $contentType;
/**
* @var string
*/
private $contents;
/**
* @param string $fileName
* @param string|null $path
* @param string|null $contentType
* @param string $contents
*/
public function __construct(
string $fileName,
?string $path,
?string $contentType,
string $contents
) {
$this->fileName = $fileName;
$this->path = $path;
$this->contentType = $contentType;
$this->contents = $contents;
}
/**
* @return string
*/
public function getFileName(): string
{
return $this->fileName;
}
/**
* @return string|null
*/
public function getPath(): ?string
{
return $this->path;
}
/**
* @return string|null
*/
public function getContentType(): ?string
{
$contentType = $this->contentType;
if (empty($contentType) && $this->contents) {
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$contentType = $finfo->buffer($this->contents);
}
if (empty($contentType) && $this->path) {
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$contentType = $finfo->file($this->path);
}
return $contentType;
}
/**
* @return string
*/
public function getContents(): string
{
return $this->contents;
}
/**
* @param string $path
* @return File
*/
public static function fromPath(string $path): File
{
if (!file_exists($path)) {
throw new RuntimeException(sprintf('`%s` does not exist.', $path));
}
$fileName = basename($path);
$contentType = mime_content_type($path);
$contents = file_get_contents($path);
return new File($fileName, $path, $contentType, $contents);
}
/**
* @param StreamInterface $stream
* @param string $fileName
* @return File
*/
public static function fromStream(StreamInterface $stream, string $fileName): File
{
$contents = (string) $stream;
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$contentType = $finfo->buffer($contents);
return new File($fileName, null, $contentType, $contents);
}
}Proposed method signatures
|
| ->withHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0') | ||
| ->withHeader('Pragma', 'public') | ||
| ->withBody($file); | ||
| ->withBody($this->streamFactory->createStreamFromFile($file->getPath())); |
There was a problem hiding this comment.
The contents should already be in the File, this will cause double hit on the file system. I would simply do $this->streamFactory->createStream($file->getContents())
There was a problem hiding this comment.
If the contents would be loaded automatically, is this okay with huge files?
|
Let’s make sure we update the README as well to reflect the addition of the two new methods. |
|
Currently the And the current behaviour is such that if the user provides headers, then the default headers would never be added. But what if the user wanted to add other headers than the ones defined in the defaults? I guess we should check, if the user provided a header key, and if not, then the default would be applied. (tomorrow) |
|
We should set default to not cache I suppose. Let's get rid of As for |
|
Now there are lots of if conditions for the headers. Probably I could also use |
|
The problem with |
|
We are loading the whole contents of the file in The function $extension = mb_strtolower(pathinfo($path, PATHINFO_EXTENSION));
switch($extension) {
case 'json':
$contentType = 'application/json';
break;
default:
$contentType = mime_content_type($path);
}The same holds for the $extension = mb_strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
switch ($extension) {
case 'json':
$contentType = 'application/json';
break;
default:
$finfo = new finfo(FILEINFO_MIME_TYPE);
$contentType = $finfo->buffer($contents);
}It is possible that the file name of the file stream might be extracted from the metadata |
|
For the memory exhaustion issue there’s no real way to avoid that as we would need to defer the reading of the file to the moment it’s being output and buffer read it as its being emitted. I think this can’t be handled from our end. As for content type detection there’s too many to parse manually. Maybe we could add an optional content type parameter to the static creator methods so it can be user defined. |
I think that the wrong way to resolve the issue was initially chosen. As a user, i would like to use code like this: return $response->withFileDownload('bigBackup.gz');
# or
$handler = fopen('bigBackup.gz', 'r');
return $response->withFileDownload($handler);And i not want to create
If you don't want to follow the path of Symfony/Zend and others where we just create an instance like /**
* Note: This method is not part of the PSR-7 standard.
*
* @param string|resource|StreamInterface $file
* @param string|null $name
*
* @return static
*/
public function withFileAttach($file, ?string $name = null): ResponseInterface
{
$disposition = 'attachment';
$filename = $name ?? (is_string($file) ? basename($file) : '');
# filter filename
# TODO: test on unicode
$filename = preg_replace(['/[\x00-\x1F\x7F\"\*<>\?|;]/', '/[\\\:\/]/'], ['', '-'], $filename);
if (strlen($filename)) {
$disposition .= '; filename="' . $filename . '"';
}
return $this->withFile($file, $name)
->withHeader('Content-Disposition', $disposition);
}
/**
* Note: This method is not part of the PSR-7 standard.
*
* This method prepares the response object to return a file response to the
* client without 'Content-Disposition' header which default 'inline'
*
* @param string|resource|StreamInterface $file
* @param string|null $name
*
* @return ResponseInterface
* @throws RuntimeException If the file cannot be opened.
* @throws InvalidArgumentException If the mode is invalid.
*/
public function withFile($file = null, ?string $name = null): ResponseInterface
{
$response = $this->response;
if (is_resource($file)) {
$response = $response->withBody($this->streamFactory->createStreamFromResource($file));
} elseif (is_string($file)) {
# thrown exception if the file is not exists/readable
$response = $response->withBody($this->streamFactory->createStreamFromFile($file));
} elseif ($file instanceof StreamInterface) {
$response = $response->withBody($file);
}
if (!$response->hasHeader('Content-Type')) {
# TODO: get MIME type on file extension
$contentType = is_string($file) ? mime_content_type($file) : 'application/octet-stream';
$response = $response->withHeader('Content-Type', $contentType);
}
return new static($response, $this->streamFactory);
}usage: return $response->withFileAttach("../data/{$filePath}", 'backup.zip');
return $response->withFile('inline.pdf');
return $response->withFile('fresh_log')
->withHeader('Content-Type', 'text/plain-text');
# or:
return $response->withFile($handler, 'log.txt'); |
|
@roxblnfk those are all fair points. I think that I always get caught up in type hinting method parameters for such methods when they don't theoretically need to. I like the implementation you proposed, I suppose you're right, the user probably doesn't want to have to create a file then pass it to the method.. It needs to be more user friendly. @adriansuter I'm going to close this PR and re-open a new one with the new direction. |
|
Closing this. Follow discussion in #91 |
This PR addresses the issue #82.
Question: What is the best way for a user to change the file name of the download (Content-Disposition)?
Content-Dispositionheader after calling this method.FileResponseConfig) to the method which would contain the file name. This allows other configs as well - for example the desiredContent-Type, or whether the disposition isattachmentorinline.So far I think the following configs should be possible:
null, the method should try to guess the mime-type from the file name. Fallback isapplication/octet-stream.inlineorattachment. Defaults toattachment.true, the method would set an HTTP ETag header automatically.true, the method would set theLast-Modifiedheader based on thefilemtime.null, this should be used as file name in theContent-Dispositionheader. Defaults to the file name of the file given to the method.Not sure about this config (found in Silex code mentioned in the original issue slimphp/Slim#2536):
Note
Currently there are no unit tests for this method.