Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ ifeq ($(ARCH),arm64)
endif

TTY := $(shell [ -t 0 ] && echo -it)
HOST_USER := $(shell id -u):$(shell id -g)

PHP_VERSION := $(shell sed -n 's/.*"php": *"^\([0-9]*\.[0-9]*\)".*/\1/p' composer.json)
IMAGE_VERSION := 1.0.0
PHP_IMAGE := gustavofreze/php:${PHP_VERSION}-cli-${IMAGE_VERSION}
WORKSPACE := /var/www/html

DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --net=host -v ${PWD}:${WORKSPACE} ${PHP_IMAGE}
DOCKER_RUN = docker run ${PLATFORM} -u ${HOST_USER} --rm ${TTY} --net=host \
-e COMPOSER_HOME=/tmp/composer \
-v ${PWD}:${WORKSPACE} ${PHP_IMAGE}

RESET := \033[0m
GREEN := \033[0;32m
Expand Down Expand Up @@ -55,7 +58,6 @@ show-image: ## Show the pinned PHP tooling image

.PHONY: clean
clean: ## Remove dependencies and generated artifacts
@sudo chown -R ${USER}:${USER} ${PWD}
@rm -rf reports vendor .phpunit.cache *.lock

.PHONY: help
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ $rawBody = $request->rawBody(); # exact bytes, u

#### Creating a response

Each helper returns a PSR-7 `ResponseInterface` and defaults to `application/json`:
Each body-carrying helper returns a PSR-7 `ResponseInterface` and defaults to `application/json`. `noContent()` is
the exception: it has no payload to describe, so it declares no media type unless you pass one.

```php
<?php
Expand Down Expand Up @@ -153,9 +154,10 @@ use TinyBlocks\Http\Server\Response;
Response::from(body: ['status' => 'accepted'], code: Code::ACCEPTED);
```

Attach additional headers via varargs of `Headerable`. They add to the `application/json` default rather than replacing
it, so a response carrying a `Link` or a `Cache-Control` header still declares its media type. Passing a `ContentType`
is what changes the media type, and it replaces the default instead of appending a second one:
Attach additional headers via varargs of `Headerable`. On a response with a body they add to the `application/json`
default rather than replacing it, so a response carrying a `Link` or a `Cache-Control` header still declares its media
type. Passing a `ContentType` is what changes the media type, and it replaces the default instead of appending a second
one. On `noContent()` there is no default to replace, and a `ContentType` you pass is carried as given:

```php
<?php
Expand Down
2 changes: 1 addition & 1 deletion src/Internal/Server/Response/InternalResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static function createWithoutBody(Code $code, Headerable ...$headers): Re
return new InternalResponse(
body: StreamFactory::fromEmptyBody()->write(),
code: $code,
headers: ResponseHeaders::fromWithDefaultContentType(...$headers),
headers: ResponseHeaders::fromWithoutDefaultContentType(...$headers),
protocolVersion: ProtocolVersion::default(),
customReasonPhrase: null
);
Expand Down
18 changes: 15 additions & 3 deletions src/Internal/Server/Response/ResponseHeaders.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,35 @@ private static function mergeInto(Headerable $header, array $merged): array
return $merged;
}

public static function fromWithDefaultContentType(Headerable ...$headers): ResponseHeaders
/**
* Merges the supplied headers exactly as given, with no media type of the library's own. A response built
* without a body has no payload to describe, and announcing one over zero bytes misleads a strict client
* into parsing a body that was never sent.
*/
public static function fromWithoutDefaultContentType(Headerable ...$headers): ResponseHeaders
{
$merged = [];

foreach ($headers as $header) {
$merged = ResponseHeaders::mergeInto(header: $header, merged: $merged);
}

$provided = new ResponseHeaders(headers: $merged);
return new ResponseHeaders(headers: $merged);
}

public static function fromWithDefaultContentType(Headerable ...$headers): ResponseHeaders
{
$provided = ResponseHeaders::fromWithoutDefaultContentType(...$headers);

if ($provided->hasHeader(name: ResponseHeaders::CONTENT_TYPE)) {
return $provided;
}

$contentType = ContentType::applicationJson(charset: Charset::UTF_8);

return new ResponseHeaders(headers: ResponseHeaders::mergeInto(header: $contentType, merged: $merged));
return new ResponseHeaders(
headers: ResponseHeaders::mergeInto(header: $contentType, merged: $provided->toArray())
);
}

private function findKey(string $name): ?string
Expand Down
4 changes: 4 additions & 0 deletions src/Server/Responses.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ public static function accepted(mixed $body, Headerable ...$headers): ResponseIn
/**
* Creates a response with a 204 No Content status.
*
* Unlike the body-carrying helpers, this one adds no default media type: there is no payload to describe, and
* a Content-Type over zero bytes makes a strict client parse a body that was never sent. A ContentType passed
* by the caller is still honored.
*
* @param Headerable ...$headers Optional additional headers for the response.
* @return ResponseInterface The generated 204 No Content response.
*/
Expand Down
69 changes: 39 additions & 30 deletions tests/Unit/Server/HeadersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use PHPUnit\Framework\TestCase;
use TinyBlocks\Http\CacheControl;
use TinyBlocks\Http\Charset;
use TinyBlocks\Http\ContentType;
use TinyBlocks\Http\ResponseCacheDirectives;
use TinyBlocks\Http\Server\Response;
Expand All @@ -16,7 +17,7 @@ final class HeadersTest extends TestCase
public function testWithoutHeaderWhenAbsentThenIsNoOp(): void
{
/** @Given an HTTP response without the target header */
$response = Response::noContent();
$response = Response::noContent(ContentType::applicationJson(charset: Charset::UTF_8));

/** @When the missing header is requested to be removed */
$actual = $response->withoutHeader('X-Trace');
Expand Down Expand Up @@ -62,18 +63,40 @@ public function testNoContentWhenContentTypeIsPdfThenHeaderReflectsIt(): void
self::assertSame('application/pdf', $actual->getHeaderLine('Content-Type'));
}

public function testNoContentWhenInvokedThenCarriesDefaultContentType(): void
public function testNoContentWhenInvokedThenCarriesNoContentType(): void
{
/** @When a no-content response is created */
$response = Response::noContent();

/** @Then the response carries the default Content-Type header */
self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $response->getHeaders());
/** @Then no Content-Type is announced, because there is no payload for it to describe */
self::assertFalse($response->hasHeader('Content-Type'));
self::assertSame([], $response->getHeaders());
}

public function testNoContentWhenContentTypeGivenThenTheExplicitValueSurvives(): void
{
/** @Given a media type the caller chose for a bodiless response */
$contentType = ContentType::applicationJson(charset: Charset::UTF_8);

/** @When the response is created with it */
$actual = Response::noContent($contentType);

/** @Then the caller's value is carried, since dropping the default never drops an explicit header */
self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders());
}

public function testOkWhenNoContentTypeGivenThenCarriesDefaultContentType(): void
{
/** @When a response with a body is created without a media type */
$actual = Response::ok(body: ['name' => 'Hydra']);

/** @Then the default Content-Type still applies, because there is a payload to describe */
self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders());
}

public function testWithHeaderWhenSameHeaderSetTwiceThenLastValueWins(): void
{
/** @Given an HTTP response with a default Content-Type */
/** @Given an HTTP response carrying no Content-Type */
$response = Response::noContent();

/** @When we add the 'Content-Type' header twice with different values */
Expand Down Expand Up @@ -122,7 +145,7 @@ public function testWithoutHeaderWhenCaseMismatchedThenStillRemovesHeader(): voi

/** @Then the header is no longer present */
self::assertFalse($actual->hasHeader('X-Trace'));
self::assertSame(['Content-Type' => ['application/json; charset=utf-8']], $actual->getHeaders());
self::assertSame([], $actual->getHeaders());
}

public function testWithHeaderWhenCaseMismatchedThenReplacesExistingHeader(): void
Expand All @@ -135,10 +158,7 @@ public function testWithHeaderWhenCaseMismatchedThenReplacesExistingHeader(): vo

/** @Then the original casing is preserved and the value replaced */
self::assertSame(['second'], $actual->getHeader('X-Trace'));
self::assertSame(
['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['second']],
$actual->getHeaders()
);
self::assertSame(['X-Trace' => ['second']], $actual->getHeaders());
}

public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): void
Expand Down Expand Up @@ -187,10 +207,7 @@ public function testWithAddedHeaderWhenCaseMismatchedThenMatchesExistingHeader()

/** @Then the value is appended preserving the original case of the header name */
self::assertSame(['first', 'second'], $actual->getHeader('X-Trace'));
self::assertSame(
['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['first', 'second']],
$actual->getHeaders()
);
self::assertSame(['X-Trace' => ['first', 'second']], $actual->getHeaders());
}

public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue(): void
Expand All @@ -203,10 +220,7 @@ public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue()

/** @Then the header is created carrying the given value */
self::assertSame(['only-value'], $actual->getHeader('X-Trace'));
self::assertSame(
['Content-Type' => ['application/json; charset=utf-8'], 'X-Trace' => ['only-value']],
$actual->getHeaders()
);
self::assertSame(['X-Trace' => ['only-value']], $actual->getHeaders());
}

public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt(): void
Expand Down Expand Up @@ -249,7 +263,7 @@ public function testWithAddedHeaderWhenDistinctValueGivenThenAppendsToExistingHe
self::assertSame(['first', 'second'], $actual->getHeader('X-Trace'));
}

public function testNoContentWhenMultipleHeaderablesGivenThenContentTypeReplacesDefault(): void
public function testNoContentWhenMultipleHeaderablesGivenThenContentTypeIsPreserved(): void
{
/** @Given a Cache-Control header */
$cacheControl = CacheControl::fromResponseDirectives(ResponseCacheDirectives::noStore());
Expand All @@ -260,7 +274,7 @@ public function testNoContentWhenMultipleHeaderablesGivenThenContentTypeReplaces
/** @When a response is created with both */
$actual = Response::noContent($cacheControl, $contentType);

/** @Then the Content-Type header replaces the default */
/** @Then the Content-Type header is the one the caller passed */
self::assertSame(['text/plain'], $actual->getHeader('Content-Type'));
}

Expand Down Expand Up @@ -290,13 +304,11 @@ public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRe
self::assertSame($expected, $actual->getHeaderLine('Cache-Control'));
self::assertSame([$expected], $actual->getHeader('Cache-Control'));

/** @And the default Content-Type sits beside it, because a caller header adds rather than replaces */
$expectedHeaders = [...$cacheControl->toArray(), 'Content-Type' => ['application/json; charset=utf-8']];

self::assertSame($expectedHeaders, $actual->getHeaders());
/** @And nothing sits beside it, because a bodiless response invents no media type of its own */
self::assertSame($cacheControl->toArray(), $actual->getHeaders());
}

public function testWithHeaderWhenChainedWithDistinctKeysThenBothPresentAlongsideDefault(): void
public function testWithHeaderWhenChainedWithDistinctKeysThenBothArePresent(): void
{
/** @Given an HTTP response */
$response = Response::noContent();
Expand All @@ -306,10 +318,7 @@ public function testWithHeaderWhenChainedWithDistinctKeysThenBothPresentAlongsid
->withHeader('X-ID', '100')
->withHeader('X-NAME', 'Xpto');

/** @Then both custom headers are present alongside the default Content-Type */
self::assertSame(
['Content-Type' => ['application/json; charset=utf-8'], 'X-ID' => ['100'], 'X-NAME' => ['Xpto']],
$actual->getHeaders()
);
/** @Then both custom headers are present */
self::assertSame(['X-ID' => ['100'], 'X-NAME' => ['Xpto']], $actual->getHeaders());
}
}
6 changes: 3 additions & 3 deletions tests/Unit/Server/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -631,17 +631,17 @@ public function testGetBodyWhenMetadataRequestedAfterCloseThenReturnsEmptyArray(
self::assertSame([], $metadata);
}

public function testNoContentWhenUnrelatedHeaderGivenThenKeepsDefaultContentType(): void
public function testNoContentWhenUnrelatedHeaderGivenThenAddsNoContentType(): void
{
/** @Given a header that says nothing about the media type */
$link = Link::to(uri: '/dragons?page=2', relation: LinkRelation::NEXT);

/** @When a bodiless response is created with that header */
$actual = Response::noContent($link);

/** @Then the header is carried and the default Content-Type still applies */
/** @Then the header is carried and no media type is invented for the absent body */
self::assertSame(['</dragons?page=2>; rel="next"'], $actual->getHeader('Link'));
self::assertSame(['application/json; charset=utf-8'], $actual->getHeader('Content-Type'));
self::assertSame([], $actual->getHeader('Content-Type'));
}

public function testResponseFacadeForbidsInstantiationThroughAPrivateConstructor(): void
Expand Down