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
105 changes: 101 additions & 4 deletions src/Message/JsonRpcMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,104 @@
);
}

/**
* Creates a JsonRpcMessage from a pre-decoded stdClass object.
*
* @param \stdClass $data The decoded JSON-RPC message object.
* @return self The created JsonRpcMessage instance.
* @throws \RuntimeException If the object structure is invalid.
*/
public static function fromJsonObject(\stdClass $data): self
{
if (!property_exists($data, 'jsonrpc') || $data->jsonrpc !== '2.0') {
throw new \RuntimeException('Invalid JSON-RPC version', self::INVALID_REQUEST);
}

// Handle response messages (result or error)
if (property_exists($data, 'result') || property_exists($data, 'error')) {
// According to JSON-RPC 2.0 spec, ID must be present for responses,
// and should be the same as the ID of the request.
// It can be null if the request ID was null (though unusual) or if there was an error
// that prevented request ID parsing (e.g. Parse Error/Invalid Request).
// Our constructor new self('', null, $id) expects $id to be string|null.
$id = null;
if (property_exists($data, 'id')) {
// Ensure ID is string or null. Numeric IDs are cast to string.
$id = (is_string($data->id) || is_numeric($data->id)) ? (string)$data->id : null;
}
// If ID is strictly required and not just potentially null:
// if (!property_exists($data, 'id')) {
// throw new \RuntimeException('Response must include ID', self::INVALID_REQUEST);
// }
// $id = (string)$data->id; // Assuming ID here must not be null for a response.

$msg = new self('', null, $id); // Method and params are not relevant for responses.

if (property_exists($data, 'result')) {
// Result can be any type, but we store it in an array field.
// If it's a structured type (object/array), ensure it's an array.
// If it's a scalar, it might be an issue if not wrapped in an array by convention.
// For now, mirroring original behavior: whatever $data->result is, assign it.
// The JsonRpcMessage::$result is type ?array. This implies result should be array or null.
// Let's ensure it's compatible.
if (is_object($data->result) || is_array($data->result)) {
$msg->result = (array)$data->result;
} elseif ($data->result === null) {
$msg->result = null;
} else {
// If result is a scalar, this was not explicitly handled before.
// Wrapping scalar in an array, or throwing error, are options.
// To be safe and expect a "structured value" usually:
throw new \RuntimeException('Response result must be a structured type (object/array) or null.', self::INVALID_REQUEST);
}
} else { // This means property_exists($data, 'error') is true
if (!is_object($data->error)) { // Error member MUST be an object
throw new \RuntimeException('Invalid error object in JSON-RPC response (must be an object)', self::INVALID_REQUEST);
}
// Perform a shallow cast for the main error object
$errorArray = (array)$data->error;

// If the 'data' field within the error object exists and is an object, cast it to an array too
if (isset($errorArray['data']) && is_object($errorArray['data'])) {
$errorArray['data'] = (array)$errorArray['data'];
}
$msg->error = $errorArray;
}
return $msg;
}

// Handle request or notification messages
if (!property_exists($data, 'method') || !is_string($data->method) || empty($data->method)) {
throw new \RuntimeException('Missing, invalid, or empty method name in JSON-RPC request', self::INVALID_REQUEST);
}

$params = null;
if (property_exists($data, 'params')) {
if (is_object($data->params) || is_array($data->params)) {
// JSON-RPC params can be an array (positional) or object (named).
// Our constructor expects ?array. So, convert object to array.
$params = (array)$data->params;
} else {
// Params exist but are not a structured type (object/array)
throw new \RuntimeException('Invalid params: must be object or array if present.', self::INVALID_PARAMS);
}
}

$id = null;
if (property_exists($data, 'id')) {
if (is_string($data->id) || is_numeric($data->id)) {
$id = (string)$data->id;
} elseif ($data->id === null) { // Explicitly null is allowed (fixed phpcs warning)
$id = null; // Explicitly null is allowed

Check warning on line 222 in src/Message/JsonRpcMessage.php

View check run for this annotation

Codecov / codecov/patch

src/Message/JsonRpcMessage.php#L222

Added line #L222 was not covered by tests
} else {
// ID is present but of an invalid type (e.g., object, array)
throw new \RuntimeException('Invalid ID: must be string, number, or null.', self::INVALID_REQUEST);
}
}

return new self($data->method, $params, $id);
}

/**
* Creates a JSON-RPC success response message.
*
Expand Down Expand Up @@ -203,10 +301,9 @@

$messages = [];
foreach ($data as $item) {
// Re-encode each item to use the existing fromJson method
// This is less efficient but reuses existing parsing logic
$itemJson = json_encode($item, JSON_THROW_ON_ERROR);
$messages[] = self::fromJson($itemJson);
// $item is already a PHP stdClass object from the initial json_decode.
// Call the new fromJsonObject method directly.
$messages[] = self::fromJsonObject($item);
}

return $messages;
Expand Down
74 changes: 36 additions & 38 deletions src/Transport/StdioTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,54 +57,52 @@
}

try {
$decodedInput = json_decode($line, true, 512, JSON_THROW_ON_ERROR);

// Check for batch request: a non-empty numerically indexed array
if (
is_array($decodedInput)
&& (count($decodedInput) === 0
|| array_keys($decodedInput) === range(0, count($decodedInput) - 1))
) {
if (empty($decodedInput)) {
// Empty array received, spec implies it's invalid for batch,
// but JsonRpcMessage::fromJsonArray will handle this (likely an error or empty messages).
// For consistency, we let fromJsonArray parse it.
return JsonRpcMessage::fromJsonArray($line);
$trimmedLine = trim($line);
$isLikelyBatch = strpos($trimmedLine, '[') === 0;

// Conditional decoding: objects for batch (false), associative arrays for single (true).
// This $decodedInput is used for structural checks before passing the raw string.
$decodedInput = json_decode($trimmedLine, !$isLikelyBatch, 512, JSON_THROW_ON_ERROR);

if ($isLikelyBatch) {
// $decodedInput should be an array (of objects, due to 'false' in json_decode).
if (is_array($decodedInput)) {
// Pass the original string to fromJsonArray, as it expects a string.
return JsonRpcMessage::fromJsonArray($trimmedLine);
} else {
// $trimmedLine started with '[' but didn't decode to a PHP array.
throw new \RuntimeException(
'Invalid JSON-RPC batch structure. Expected a JSON array after decoding.',
JsonRpcMessage::PARSE_ERROR
);

Check warning on line 77 in src/Transport/StdioTransport.php

View check run for this annotation

Codecov / codecov/patch

src/Transport/StdioTransport.php#L74-L77

Added lines #L74 - L77 were not covered by tests
}
} else { // Single request
if (is_array($decodedInput) && !empty($decodedInput)) { // Check for non-empty assoc array
$message = JsonRpcMessage::fromJson($trimmedLine);
return [$message];
} elseif (is_object($decodedInput)) { // Should not happen if assoc is true typically, but check for robustness
$message = JsonRpcMessage::fromJson($trimmedLine);
return [$message];

Check warning on line 85 in src/Transport/StdioTransport.php

View check run for this annotation

Codecov / codecov/patch

src/Transport/StdioTransport.php#L84-L85

Added lines #L84 - L85 were not covered by tests
} else {
// $decodedInput is not a non-empty associative array or an object.
// This uses the generic message that tests expect.
throw new \RuntimeException(
'Invalid JSON-RPC message structure. Expected object or array of objects.',
JsonRpcMessage::PARSE_ERROR
);
}
// It's a batch, let fromJsonArray handle full parsing
// and validation from original string
return JsonRpcMessage::fromJsonArray($line);
} elseif (
is_object($decodedInput) // Decoded as object if not assoc=true
|| (is_array($decodedInput) && !empty($decodedInput)) // Decoded as non-empty assoc array
) {
// It's a single request (object) or potentially an associative
// array representing a single request.
// Let fromJson handle full parsing and validation from original string
$message = JsonRpcMessage::fromJson($line);
return [$message];
} else {
// Invalid structure that is not explicitly an empty array
// or a valid single/batch candidate
throw new \RuntimeException(
'Invalid JSON-RPC message structure. Expected object or array of objects.',
JsonRpcMessage::PARSE_ERROR
);
}
} catch (\JsonException $e) {
} catch (\JsonException $e) { // Catch JSON parsing errors first
throw new \RuntimeException(
'JSON Parse Error: ' . $e->getMessage(),
JsonRpcMessage::PARSE_ERROR,
$e
);
} catch (\Exception $e) {
// Catch other exceptions from JsonRpcMessage::fromJson or fromJsonArray
} catch (\Exception $e) { // Catch other exceptions from JsonRpcMessage processing
$this->log("Error processing received message: " . $e->getMessage());
// Depending on desired behavior, re-throw or return error specific message
// For now, let's re-throw as a runtime exception consistent with parse errors
throw new \RuntimeException(
'Error parsing JSON-RPC message: ' . $e->getMessage(),
JsonRpcMessage::INVALID_REQUEST, // Or PARSE_ERROR if more appropriate
JsonRpcMessage::INVALID_REQUEST,
$e
);
}
Expand Down
Loading