Skip to content
This repository has been archived by the owner on Jan 29, 2020. It is now read-only.

Add DownloadResponse Class #361

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from

Conversation

settermjd
Copy link

@settermjd settermjd commented Jun 10, 2019

Why is the new feature needed? What purpose does it serve?

After doing a bit of searching, I didn't find a class that would send a CSV response. There were the Text, JSON, HTML, Redirect, XML, and Empty response classes, but nothing specific to CSV. So I created this PR to add a CSV response class, which can send both plain CSV text as well as a response that will be interpreted by the client as a downloadable file.

How will users use the new feature?

Users can use the CSV response class very similarly to how they use the existing response classes. The only difference is that if they supply a file name as the third parameter to the constructor, then a download response will be sent, not a textual response.

Copy link
Member

@michalbundyra michalbundyra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @settermjd !

I am not maintainer so I can't approve this PR, but please see my comments. As I said I don't know if this feature is gonna be accepted.

I would consider passing the array to CsvResponse and build the body inside from the array. Similar we have with JSON response - we are passing array and json is build inside.

src/Response/CsvResponse.php Outdated Show resolved Hide resolved
src/Response/CsvResponse.php Show resolved Hide resolved
*
* Allows creating a CSV response by passing a string to the constructor;
* by default, sets a status code of 200 and sets the Content-Type header to
* text/csv.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't be better to accept also/only array with data and process it here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, hadn't considered that. Thanks.

src/Response/CsvResponse.php Outdated Show resolved Hide resolved
src/Response/DownloadResponseTrait.php Outdated Show resolved Hide resolved
src/Response/DownloadResponseTrait.php Show resolved Hide resolved

}

public function invalidHeadersWhenDownloading()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can have RTH here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, what's RTH?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return Type Hint declaration:

public function invalidHeadersWhenDownloading() : array
                                                ^^^^^^^^

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad.

test/Response/CsvResponseTest.php Outdated Show resolved Hide resolved
test/Response/CsvResponseTest.php Show resolved Hide resolved
test/Response/CsvResponseTest.php Show resolved Hide resolved
CHANGELOG.md Outdated Show resolved Hide resolved
Copy link
Member

@weierophinney weierophinney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of this a lot. However, I would argue we need two things here:

  • A general DownloadResponse for specifying file downloads. This could build on your provided DownloadResponseTrait. This should allow for either specifying string content as the download OR a filename; the latter is useful, as it can prevent the need for having the full file contents in memory at once.
  • Three different constructors for the CsvResponse:
    • One which accepts a CSV-formatted string to use.
    • One which accepts a CSV file to use.
    • One which accepts an array of arrays to use, as well as (optionally) a delimiter character and enclosure character. This would build the CSV string.

You can do multiple constructors by using static constuctor methods:

  • CsvResponse::fromString()
  • CsvResponse::fromFile()
  • CsvResponse::fromArray() (or fromTraversable())

Finally, this could be interesting either as part of Diactoros, or as a stand-alone package that uses PSR-17 to create the initial stream and response instances. If you want to push it here, we can definitely maintain it, though.

docs/book/v2/custom-responses.md Outdated Show resolved Hide resolved
src/Response/CsvResponse.php Outdated Show resolved Hide resolved
@michalbundyra michalbundyra added this to the 2.2.0 milestone Oct 8, 2019
@settermjd
Copy link
Author

Sorry that I let this slip. I'll get it finalised this week, all being well.

@settermjd
Copy link
Author

I like the idea of this a lot. However, I would argue we need two things here:

* A general `DownloadResponse` for specifying file downloads. This could build on your provided `DownloadResponseTrait`. This should allow for either specifying string content as the download OR a filename; the latter is useful, as it can prevent the need for having the full file contents in memory at once.

* Three different constructors for the `CsvResponse`:
  
  * One which accepts a CSV-formatted string to use.
  * One which accepts a CSV file to use.
  * One which accepts an array of arrays to use, as well as (optionally) a delimiter character and enclosure character. This would build the CSV string.

You can do multiple constructors by using static constuctor methods:

* `CsvResponse::fromString()`

* `CsvResponse::fromFile()`

* `CsvResponse::fromArray()` (or `fromTraversable()`)

Finally, this could be interesting either as part of Diactoros, or as a stand-alone package that uses PSR-17 to create the initial stream and response instances. If you want to push it here, we can definitely maintain it, though.

Hey @weierophinney, I've been having a think about this one. As I see it at the moment, keeping it part of Diactoros, to me, makes the most sense. I'll aim to get it implemented this week.

Matthew

@settermjd settermjd changed the title Add CSV Response Class [WIP] Add CSV Response Class Oct 29, 2019
@settermjd
Copy link
Author

I've made a start on working the three methods, however, I think they're going to take a bit of time to create and test thoroughly enough to feel comfortable with.

@weierophinney
Copy link
Member

I think they're going to take a bit of time to create and test thoroughly enough to feel comfortable with.

No worries - we're pretty busy finalizing the Laminas migration tooling as it is. 😄

@settermjd
Copy link
Author

I think they're going to take a bit of time to create and test thoroughly enough to feel comfortable with.

No worries - we're pretty busy finalizing the Laminas migration tooling as it is. smile

I can only imagine.

@michalbundyra
Copy link
Member

michalbundyra commented Nov 12, 2019

@settermjd Would you be able to resolve conflicts on this PR, please? Not sure what exactly happened but github is not showing proper diff on custom-responses.md file. Thanks !

EDIT: Is it not WIP anymore, right? If so, please update PR subject.

@weierophinney weierophinney modified the milestones: 2.2.0, 2.3.0 Nov 12, 2019
michalbundyra and others added 5 commits December 7, 2019 18:55
This class is almost identical to TextResponse, except that it sets the
Content-Type header to text/csv.
After doing a bit of searching, I didn't find anything that would help
with sending a downloaded response, instead of the default, which is to
send a text response. So I created this one, which sends six headers
that ensure that the response sent will be interpreted by the client as
a downloadable file.
This change update the CsvResponse to be able to either send a plain CSV
text response, or to send the response as a downloadable file instead.
In the style of the existing Response class documentation, this change
adds a section covering the new CsvResponse class.
@settermjd
Copy link
Author

@michalbundyra, sorry for disappearing. I'm working on it this week until it's completed.

The intent here is to kick of the refactor of the trait to a class and
to follow it up progressively with further commits that complete the
refactor.
Over the course of several projects I've found this package to be
excellent for mocking the filesystem, which is required for the
DownloadResponse class.
This is following on from what mwop suggested in that the body of the
response can be set from either a string or a file. It's making use of a
Stream object and the body is instantiated similarly to XmlResponse as
how that works makes a lot of sense in this case.
After thinking about this at some length, it makes sense to me (at least
at this stage) to set the content-type and content-disposition headers
from values supplied ($contentType and $filename values respectively) to
the constructor. This, I think, will be cleaner and more maintainable.
@Xerkus
Copy link
Member

Xerkus commented Dec 28, 2019

I would like to reiterate here for the record my objections I voiced before in chat for approach introducing specialized response types as opposed to straight up factories.

Functionality introduced here is a factory method pattern. I think what it tries to achieve here violates single responsibility principle in that psr message represents complex http response structure and Csv and Download responses here are building response. It does not introduce any new or changed behavior and as such does not call for an inheritance.

Look at CsvResponse constructor from PR:

    public function __construct($text, int $status = 200, string $filename = '', array $headers = [])
    {
        if (is_string($filename) && $filename !== '') {
            $headers = $this->prepareDownloadHeaders($filename, $headers);
        }
        parent::__construct(
            $this->createBody($text),
            $status,
            $this->injectContentType('text/csv; charset=utf-8', $headers)
        );
    }

Same code as factory method using no inheritance:

class DownloadResponseFactory
{
    public function prepareResponse($text, int $status = 200, string $filename = '', array $headers = []) : Response
    {
        if (is_string($filename) && $filename !== '') {
            $headers = $this->prepareDownloadHeaders($filename, $headers);
        }
        $headers = $this->injectContentType('text/csv; charset=utf-8', $headers);
        return new Responsex(
            $this->createBody($text),
            $status,
            $headers
        );
    }
}

Same functionality, same code, single purpose. No inherited behavior adding inherited complexity cost. Simpler. Makes it easier to test with high confidence. High confidence in general means better long term maintainability.

As added bonus, if extracted as a factory/builder it gives extra flexibility:

Convenience factory to create from file:

class DownloadResponseFactory
{
           public function prepareFromFile(string $file, int $status = 200, array $headers = []) : Response;
}

May be partial downloads?

class DownloadResponseFactory
{
           public function preparePartialFromFile(int $rangeStart,  int $rangeEnd, string $file,  ...) : Response;
}

Create response object from any provider:

class CsvResponseFactory
{
           public function __construct(PsrResponseFactory $responseFactory, PsrStreamFactory $streamFactory);
}

Now, from library/framework standpoint: introduction of subclass entices users to make rigged design decisions. if ($response instanceof CsvResponse) { doLogicHere(); } appear as a valid and very appealing choice but it is a trap with implicit issues.

  • response that is not CsvResponse instance can still be csv.
  • CsvResponse can get modified and no longer contain csv data despite instance type.
  • Code looking for specific instance introduces hidden dependency that increases complexity and reduces reliability from long term maintenance standpoint.
  • Long living code gets messy over time, little details are forgotten and incorrect assumptions made at a glance that lead to subtle inconsistencies. "We get CsvResponse instances, therefore all csv responses will be of that type" and it can be true until third party middleware is used. So, see points above.

What said above comes from the point of view of someone with heavy focus on long term maintainability and defensive programming practices. It is not an absolute and there are other aspects to be considered.

This is my opinion as a developer, not as a ZF Community Review Team member

@settermjd
Copy link
Author

settermjd commented Dec 30, 2019

Hey @Xerkus, to be honest, in the chat, I didn't fully appreciate what you were trying to say. However, after reading through such detailed feedback, what you're saying makes a lot of sense to me and, to be honest, I'm thinking that your approach is the correct one. What most stands out for me is the preparePartialFromFile. That looks like a great idea.

That said, the PR is still a WIP. The CSVResponse class doesn't reflect the presence of the DownloadResponse class. I should have removed it so that it didn't cause anyone any confusion.

@settermjd settermjd changed the title [WIP] Add CSV Response Class Add DownloadResponse Class Dec 30, 2019
I think that I misunderstood what was being asked originally regarding
working with a DownloadResponseTrait and a CsvResponse. Given that, this
change is being made to create a DownloadResponse but keep the core of
the functions in the trait. Not sure if this is the correct approach,
but it makes sense to me at the moment.
@weierophinney
Copy link
Member

This repository has been closed and moved to laminas/laminas-diactoros; a new issue has been opened at laminas/laminas-diactoros#6.

@weierophinney
Copy link
Member

This repository has been moved to laminas/laminas-diactoros. If you feel that this patch is still relevant, please re-open against that repository, and reference this issue. To re-open, we suggest the following workflow:

  • Squash all commits in your branch (git rebase -i origin/{branch})
  • Make a note of all changed files (`git diff --name-only origin/{branch}...HEAD
  • Run the laminas/laminas-migration tool on the code.
  • Clone laminas/laminas-diactoros to another directory.
  • Copy the files from the second bullet point to the clone of laminas/laminas-diactoros.
  • In your clone of laminas/laminas-diactoros, commit the files, push to your fork, and open the new PR.
    We will be providing tooling via laminas/laminas-migration soon to help automate the process.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants