fix(relay): properly process plain text payload (#39)

* fix(relay): properly process plain text payload

non-json text payload is passed as is.

* style: apply TS style fixes
This commit is contained in:
Mazen Touati
2026-01-17 20:43:06 +01:00
committed by GitHub
parent ac44e19dba
commit 7b0af6feff
7 changed files with 148 additions and 13 deletions

View File

@@ -30,7 +30,6 @@ defineOptions({
const VERSION = 'v0.3.0-alpha'; // x-release-please-version
/*
* Stores.
*/

View File

@@ -29,14 +29,14 @@ class NimbusRelayRequest extends FormRequest
/**
* @return array<string, mixed>
*/
public function getBody(): array
public function getBody(): string|array
{
$body = $this->validated('body') && filled($this->validated('body'))
? $this->validated('body')
: [];
return is_string($body)
? json_decode($body, true)
? json_decode($body, true) ?? $body
: $body;
}
}

View File

@@ -69,7 +69,7 @@ class RequestRelayAction
$queryParameters = $requestRelayData->queryParameters;
$requestBody = $requestRelayData->body;
if (in_array($requestRelayData->method, ['get', 'head'])) {
if (is_array($requestBody) && in_array($requestRelayData->method, ['get', 'head'])) {
$queryParameters = array_merge(
$queryParameters,
$requestBody,
@@ -84,10 +84,17 @@ class RequestRelayAction
->withQueryParameters($queryParameters)
->when(
$requestBody !== [],
fn (PendingRequest $pendingRequest) => $pendingRequest->withBody(
json_encode($requestRelayData->body) ?: throw new RuntimeException('Cannot parse body.'),
contentType: $contentType,
),
function (PendingRequest $pendingRequest) use ($requestBody, $contentType) {
$body = match (true) {
is_string($requestBody) => $requestBody,
default => json_encode($requestBody) ?: throw new RuntimeException('Cannot parse body.'),
};
return $pendingRequest->withBody(
$body,
contentType: $contentType,
);
},
);
}

View File

@@ -13,7 +13,7 @@ readonly class RequestRelayData
{
/**
* @param array<string, string> $headers
* @param array<string, mixed> $body
* @param array<string, mixed>|string $body
* @param array<string, string|null> $queryParameters
*/
public function __construct(
@@ -21,7 +21,7 @@ readonly class RequestRelayData
public string $endpoint,
public AuthorizationCredentials $authorization,
public array $headers,
public array $body,
public array|string $body,
public ParameterBag $cookies,
public array $queryParameters = [],
) {}

View File

@@ -229,6 +229,59 @@ class RequestRelayActionFunctionalTest extends TestCase
);
}
#[TestWith(['get'])]
#[TestWith(['head'])]
public function test_it_does_not_merge_string_body_into_query_parameters_for_get_and_head_requests(string $method): void
{
// Arrange
$bodyData = 'plain text content';
$queryParameters = ['page' => '1'];
$requestData = new RequestRelayData(
method: $method,
endpoint: self::ENDPOINT,
authorization: AuthorizationCredentials::none(),
headers: [],
body: $bodyData,
cookies: new ParameterBag,
queryParameters: $queryParameters,
);
// Anticipate
Http::fake(function (Request $request) use ($queryParameters) {
// Assert that the request URL contains the original query parameters
foreach ($queryParameters as $key => $value) {
if (! str_contains($request->url(), "{$key}={$value}")) {
return Http::response(['error' => 'Missing query parameter'], 400);
}
}
// Assert that the request URL does NOT contain the string body
if (str_contains($request->url(), 'plain+text+content') || str_contains($request->url(), 'plain%20text%20content')) {
return Http::response(['error' => 'Body should not be in query parameters'], 400);
}
return Http::response([
'success' => true,
]);
});
$this->mockAuthorizationHandler();
$requestRelayAction = resolve(RequestRelayAction::class);
// Act
$response = $requestRelayAction->execute($requestData);
// Assert
$this->assertEquals(200, $response->statusCode);
}
public function test_it_sends_json_body_by_default(): void
{
// Arrange
@@ -270,6 +323,45 @@ class RequestRelayActionFunctionalTest extends TestCase
$this->assertEquals($bodyData, $response->body->body['receivedBody']);
}
public function test_it_relays_plain_text_body(): void
{
// Arrange
$bodyData = 'plain text content';
$requestData = new RequestRelayData(
method: 'post',
endpoint: self::ENDPOINT,
authorization: AuthorizationCredentials::none(),
headers: ['Content-Type' => 'text/plain'],
body: $bodyData,
cookies: new ParameterBag,
);
// Anticipate
Http::fake(function (Request $request) use ($bodyData) {
return Http::response([
'receivedBody' => $request->body(),
'bodyMatches' => $request->body() === $bodyData,
], 200);
});
$this->mockAuthorizationHandler();
$requestRelayAction = resolve(RequestRelayAction::class);
// Act
$response = $requestRelayAction->execute($requestData);
// Assert
$this->assertTrue($response->body->body['bodyMatches'], 'POST body should be sent as plain text');
$this->assertEquals($bodyData, $response->body->body['receivedBody']);
}
public function test_it_url_decodes_cookie_values(): void
{
// Arrange

View File

@@ -15,11 +15,12 @@ use Symfony\Component\HttpFoundation\InputBag;
#[CoversClass(RequestRelayData::class)]
class RequestRelayDataUnitTest extends TestCase
{
#[DataProvider('endpointsDataProvider')]
#[DataProvider('relayRequestDataDataProvider')]
public function test_it_creates_instance_from_api_request(
string $endpoint,
string $expectedEndpoint,
array $expectedParameters,
array|string $body,
): void {
// Arrange
@@ -51,7 +52,7 @@ class RequestRelayDataUnitTest extends TestCase
['key' => 'Content-Type', 'value' => 'application/json'],
['key' => 'X-Custom-Header', 'value' => '::value::'],
],
'body' => $body = ['test' => 'data'],
'body' => $body,
],
);
@@ -90,48 +91,62 @@ class RequestRelayDataUnitTest extends TestCase
);
}
public static function endpointsDataProvider(): Generator
public static function relayRequestDataDataProvider(): Generator
{
yield 'simple path without params' => [
'endpoint' => '/api/test',
'expectedEndpoint' => '/api/test',
'expectedParameters' => [],
'body' => ['test' => 'data'],
];
yield 'simple path with single param' => [
'endpoint' => '/api/test?parameter-1=value',
'expectedEndpoint' => '/api/test',
'expectedParameters' => ['parameter-1' => 'value'],
'body' => ['test' => 'data'],
];
yield 'absolute URL without params' => [
'endpoint' => 'https://127.0.0.1/api/test',
'expectedEndpoint' => 'https://127.0.0.1/api/test',
'expectedParameters' => [],
'body' => ['test' => 'data'],
];
yield 'absolute URL with multiple params including broken' => [
'endpoint' => 'https://127.0.0.1/api/test?key=1&key-2=&broken',
'expectedEndpoint' => 'https://127.0.0.1/api/test',
'expectedParameters' => ['key' => '1', 'key-2' => ''],
'body' => ['test' => 'data'],
];
yield 'absolute URL with multiple valid params' => [
'endpoint' => 'https://127.0.0.1/api/test?key=value&key-2=value-2',
'expectedEndpoint' => 'https://127.0.0.1/api/test',
'expectedParameters' => ['key' => 'value', 'key-2' => 'value-2'],
'body' => ['test' => 'data'],
];
yield 'absolute URL with port and param' => [
'endpoint' => 'https://127.0.0.1:8000/api/test?key=value',
'expectedEndpoint' => 'https://127.0.0.1:8000/api/test',
'expectedParameters' => ['key' => 'value'],
'body' => ['test' => 'data'],
];
yield 'invalid URL with port and param' => [
'endpoint' => 'http://:80?key=value',
'expectedEndpoint' => 'http://:80?key=value', // parse_url failed, return as is.
'expectedParameters' => [],
'body' => ['test' => 'data'],
];
yield 'plain text body' => [
'endpoint' => '/api/test',
'expectedEndpoint' => '/api/test',
'expectedParameters' => [],
'body' => 'plain text content',
];
}
}

View File

@@ -148,6 +148,28 @@ class NimbusRelayTest extends TestCase
cookies: [],
),
];
yield 'POST request with plain text body' => [
'payload' => [
'method' => 'POST',
'endpoint' => '/test-endpoint',
'body' => 'plain text content',
'headers' => [
['key' => 'Content-Type', 'value' => 'text/plain'],
],
],
'relayResponseStub' => new RelayedRequestResponseData(
statusCode: 200,
statusText: 'OK',
body: new PrintableResponseBody('Received plain text!'),
headers: [
'header1' => ['value1'],
],
durationMs: fake('en')->randomFloat(),
timestamp: fake('en')->dateTime()->getTimestamp(),
cookies: [],
),
];
}
#[DataProvider('validationFailureProvider')]