From c54426e6f618f1fb23db04aa29a6272bffb2d6ad Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 10 Sep 2026 12:10:36 -0300 Subject: [PATCH 1/2] build: Run the tooling container as the calling user. --- Makefile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 6915324..1caca43 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 From 2e3f356ffe033946a86a29d6c63cca3b7e7668dc Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Thu, 10 Sep 2026 12:10:36 -0300 Subject: [PATCH 2/2] fix: Stop declaring a content type on a response without a body. Response::noContent() stamped the application/json default on a zero-length body, which announces a payload that is not there and leads a strict client to parse zero bytes. The default belongs to responses that carry something to describe, so createWithoutBody merges the caller headers as given, and a ContentType passed by the caller still survives. --- README.md | 10 +-- .../Server/Response/InternalResponse.php | 2 +- .../Server/Response/ResponseHeaders.php | 18 ++++- src/Server/Responses.php | 4 ++ tests/Unit/Server/HeadersTest.php | 69 +++++++++++-------- tests/Unit/Server/ResponseTest.php | 6 +- 6 files changed, 68 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 7894ddb..c32d60e 100644 --- a/README.md +++ b/README.md @@ -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 '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 write(), code: $code, - headers: ResponseHeaders::fromWithDefaultContentType(...$headers), + headers: ResponseHeaders::fromWithoutDefaultContentType(...$headers), protocolVersion: ProtocolVersion::default(), customReasonPhrase: null ); diff --git a/src/Internal/Server/Response/ResponseHeaders.php b/src/Internal/Server/Response/ResponseHeaders.php index c63d1d2..63a31c9 100644 --- a/src/Internal/Server/Response/ResponseHeaders.php +++ b/src/Internal/Server/Response/ResponseHeaders.php @@ -26,7 +26,12 @@ 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 = []; @@ -34,7 +39,12 @@ public static function fromWithDefaultContentType(Headerable ...$headers): Respo $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; @@ -42,7 +52,9 @@ public static function fromWithDefaultContentType(Headerable ...$headers): Respo $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 diff --git a/src/Server/Responses.php b/src/Server/Responses.php index 8f8bda4..b68e6ae 100644 --- a/src/Server/Responses.php +++ b/src/Server/Responses.php @@ -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. */ diff --git a/tests/Unit/Server/HeadersTest.php b/tests/Unit/Server/HeadersTest.php index 90b52fb..285e180 100644 --- a/tests/Unit/Server/HeadersTest.php +++ b/tests/Unit/Server/HeadersTest.php @@ -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; @@ -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'); @@ -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 */ @@ -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 @@ -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 @@ -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 @@ -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 @@ -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()); @@ -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')); } @@ -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(); @@ -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()); } } diff --git a/tests/Unit/Server/ResponseTest.php b/tests/Unit/Server/ResponseTest.php index 3aece1b..0148bf1 100644 --- a/tests/Unit/Server/ResponseTest.php +++ b/tests/Unit/Server/ResponseTest.php @@ -631,7 +631,7 @@ 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); @@ -639,9 +639,9 @@ public function testNoContentWhenUnrelatedHeaderGivenThenKeepsDefaultContentType /** @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(['; 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