diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d253f87..a1ec410 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -48,6 +48,8 @@ HTTP Request → ApiServiceProvider (route registration) **MCP** (`src/Transports/Mcp/`): `McpController` (HTTP endpoint `api-mcp`, `POST /{prefix}/{mcp.path}/{version}`, registered by `ApiServiceProvider::registerMcpRoutes()` only when `laravel-api.mcp.enabled`; Origin check; GET/DELETE → 405), `McpServer` (JSON-RPC: initialize/ping/tools/list/tools/call, stateless), `McpToolset` (tool per action `controller.action`; `find()` uses the registry only, `tools()` builds descriptors or reads `ApiCache::mcpTools()`; `$defs` for templates; `'mcp' => false` opt-out). Tests enable it with `enableMcp()` in `tests/Feature/Transports/McpTest.php` (routes are registered at boot). +**JSON-RPC** (`src/Transports/JsonRpc/`): `JsonRpcController` (`api-jsonrpc`, `POST /{prefix}/{jsonrpc.path}/{version}`, `registerJsonRpcRoutes()` when `laravel-api.jsonrpc.enabled`), `JsonRpcServer` (batches, notifications, `-32602` for 422, `-32000` for other failures with `data: {status, payload}`), `OpenRpcDocument` (`rpc.discover`). Shared by both transports: `Transports\ServedVersions::of($transport)`, `Transports\NamedOperations` (`controller.action` → Operation via the registry, `'{transport}' => false` opt-out). Anything emitting JSON Schema outside OpenAPI goes through `Operations\Schema\JsonSchemas::encodable()` — PHP `[]` would encode `properties` as a list. + **ApiCache** (`src/Services/ApiCache.php`): `api:cache` freezes the prepared methods of every version and the module's route definitions; `BaseApi::getPreparedMethods()` and `BaseModule::getRouteDefinitions()` read it first. `ApiServiceProvider::makeApiRoutes()` skips route registration when `routesAreCached()` (keeps the middleware group). Tests: `ApiCache::flush()` is in `TestCase::resetStaticState()`; anything writing the cache must `ApiCache::clear()` after. Golden-master snapshots (`tests/Feature/GoldenMaster`, stored in `tests/.pest/snapshots`) pin the spec, TS types, exports, lint report and routes of every fixture API and the example. A snapshot changes only on purpose; regenerate with `vendor/bin/pest --update-snapshots`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a47203..1210342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the tool lists (cache format 2: files written by 6.1 are ignored and rebuilt by the next `api:cache`); a call finds its action through the route map without describing the others. +- **JSON-RPC 2.0.** With `laravel-api.jsonrpc.enabled`, every served version + answers JSON-RPC 2.0 at `POST /{prefix}/rpc/{version}`: methods named + `controller.action`, by-name params, through `Runtime\Dispatcher` like MCP. + The payload is the `result`; a failed action is an `error` — `-32602` for a + validation failure (status 422), `-32000` otherwise — with the status and the + payload in `data`. Batches and notifications as the specification says (204 + when nothing is answered). `rpc.discover` answers with an OpenRPC 1.3.2 + document of the version, checked against `@open-rpc/schema-utils-js` for + every fixture API and the example. `'jsonrpc' => false` in `getMethods()` + leaves an action or a controller out; `jsonrpc.versions`, `jsonrpc.path`, + `jsonrpc.middleware` configure it. +- `Transports\ServedVersions`, `Transports\NamedOperations` (the `controller.action` + index shared by MCP and JSON-RPC), `Operations\Schema\JsonSchemas::encodable()` + (empty `properties` / `$defs` written as `{}`). ### Changed diff --git a/README.md b/README.md index b3767f6..0e7100d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Build versioned APIs with automatic OpenAPI documentation generated from PHP doc - [OpenAPI 3.0 Documentation](#openapi-30-documentation) - [CRUD Scaffolding](#crud-scaffolding) - [Testing](#testing) +- [JSON-RPC 2.0](#json-rpc-20) - [MCP server](#mcp-server) - [Configuration](#configuration) - [Error Handling](#error-handling) @@ -770,6 +771,30 @@ generator uses, and also reports an action that validates its input without declaring any (`input.undeclared`). Full list of rules: [docs/linting.md](docs/en/linting.md). +## JSON-RPC 2.0 + +Every API version can be served over JSON-RPC 2.0 at `POST /{prefix}/rpc/{version}`: + +```php +'jsonrpc' => ['enabled' => true, 'versions' => ['v2'], 'middleware' => ['auth:sanctum']], +``` + +```bash +curl -s https://shop.test/api/rpc/v2 -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"user.show","params":{"id":7}}' +# {"jsonrpc":"2.0","result":{"id":"7","name":"…"},"id":1} +``` + +Each action is a method named `controller.action`, called with its input as +by-name params, through the same router, middleware and validation as an HTTP +request. The payload is the `result`; a failed action is an `error` — +`-32602` for a validation failure (status 422), `-32000` otherwise — with the +status and the payload (`errorKey` and all) in `data`. Batches and +notifications work as the specification says. `rpc.discover` answers with an +[OpenRPC](https://open-rpc.org) document of the version. Leave an action or a +controller out with `'jsonrpc' => false` in `getMethods()`. + ## MCP server Every API version can be an [MCP](https://modelcontextprotocol.io) server, so diff --git a/config/laravel-api.php b/config/laravel-api.php index 1ee38bf..3928ef5 100644 --- a/config/laravel-api.php +++ b/config/laravel-api.php @@ -45,6 +45,23 @@ 'name' => env('LARAVEL_API_MCP_NAME'), ], + // The API over JSON-RPC 2.0: one endpoint per version, at + // /{prefix}/{path}/{version}. Every action is a method named + // `controller.action`, its input passed by name; `rpc.discover` answers + // with the OpenRPC document. Leave an action or a controller out with + // `'jsonrpc' => false` in getMethods(). + 'jsonrpc' => [ + 'enabled' => env('LARAVEL_API_JSONRPC', false), + + // The versions served; empty serves every one not in hidden_versions. + 'versions' => [], + + 'path' => 'rpc', + + // Run on the endpoint itself — authentication, rate limiting. + 'middleware' => [], + ], + // The documentation page on /api/doc: @dskripchenko/openapi's standalone // reference — the versions with a switcher, search, try-it, code samples, // TypeScript types, PDF, and links that keep working (DocLink's anchors). diff --git a/docs/de/README.md b/docs/de/README.md index 142db87..322cb2a 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -680,6 +680,31 @@ OpenAPI-Generator und meldet außerdem eine Aktion, die ihre Eingaben validiert, ohne welche zu deklarieren (`input.undeclared`). Vollständige Regelliste: [linting.md](linting.md). +## JSON-RPC 2.0 + +Jede API-Version kann über JSON-RPC 2.0 unter `POST /{prefix}/rpc/{version}` bereitgestellt werden: + +```php +'jsonrpc' => ['enabled' => true, 'versions' => ['v2'], 'middleware' => ['auth:sanctum']], +``` + +```bash +curl -s https://shop.test/api/rpc/v2 -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"user.show","params":{"id":7}}' +# {"jsonrpc":"2.0","result":{"id":"7","name":"…"},"id":1} +``` + +Jede Action ist eine Methode namens `controller.action`, aufgerufen mit ihrer +Eingabe als benannte params, durch denselben Router, dieselbe Middleware und +Validierung wie eine HTTP-Anfrage. Der Payload ist das `result`; eine +fehlgeschlagene Action ist ein `error` — `-32602` bei einem +Validierungsfehler (Status 422), sonst `-32000` — mit Status und Payload +(samt `errorKey`) in `data`. Batches und Notifications funktionieren laut +Spezifikation. `rpc.discover` antwortet mit einem +[OpenRPC](https://open-rpc.org)-Dokument der Version. Eine Action oder ein +Controller wird mit `'jsonrpc' => false` in `getMethods()` ausgenommen. + ## MCP-Server Jede API-Version kann ein [MCP](https://modelcontextprotocol.io)-Server sein, diff --git a/docs/ru/README.md b/docs/ru/README.md index d0034ed..fabcaf0 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -668,6 +668,29 @@ php artisan api:lint --unrouted # плюс публичные методы, на также сообщает о действии, которое валидирует вход, но ничего не объявляет (`input.undeclared`). Полный список правил: [linting.md](linting.md). +## JSON-RPC 2.0 + +Каждую версию API можно отдавать по JSON-RPC 2.0 на `POST /{prefix}/rpc/{version}`: + +```php +'jsonrpc' => ['enabled' => true, 'versions' => ['v2'], 'middleware' => ['auth:sanctum']], +``` + +```bash +curl -s https://shop.test/api/rpc/v2 -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"user.show","params":{"id":7}}' +# {"jsonrpc":"2.0","result":{"id":"7","name":"…"},"id":1} +``` + +Каждый action — метод с именем `controller.action`, вызываемый со входом в +виде именованных params, через тот же роутер, middleware и валидацию, что и +HTTP-запрос. Payload — это `result`; неудачный action — `error`: `-32602` при +ошибке валидации (статус 422), `-32000` в остальных случаях, а в `data` — статус +и payload (включая `errorKey`). Батчи и уведомления работают по спецификации. +`rpc.discover` отвечает [OpenRPC](https://open-rpc.org)-документом версии. +Исключить action или контроллер — `'jsonrpc' => false` в `getMethods()`. + ## MCP-сервер Каждая версия API может быть [MCP](https://modelcontextprotocol.io)-сервером — diff --git a/docs/zh/README.md b/docs/zh/README.md index ebeba25..7a6ab2a 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -639,6 +639,23 @@ php artisan api:lint --unrouted # 另外检查没有动作指向的公有方法 却没有声明任何输入的动作(`input.undeclared`)。完整规则列表: [linting.md](linting.md)。 +## JSON-RPC 2.0 + +每个 API 版本都可以通过 JSON-RPC 2.0 在 `POST /{prefix}/rpc/{version}` 提供: + +```php +'jsonrpc' => ['enabled' => true, 'versions' => ['v2'], 'middleware' => ['auth:sanctum']], +``` + +```bash +curl -s https://shop.test/api/rpc/v2 -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"user.show","params":{"id":7}}' +# {"jsonrpc":"2.0","result":{"id":"7","name":"…"},"id":1} +``` + +每个操作都是名为 `controller.action` 的方法,以按名称传递的 params 作为输入调用,经过与 HTTP 请求相同的路由、中间件和验证。payload 即 `result`;失败的操作返回 `error`——验证失败(状态 422)为 `-32602`,其余为 `-32000`,`data` 中包含状态和 payload(含 `errorKey`)。批量请求和通知按规范处理。`rpc.discover` 返回该版本的 [OpenRPC](https://open-rpc.org) 文档。在 `getMethods()` 中用 `'jsonrpc' => false` 排除某个操作或控制器。 + ## MCP 服务器 每个 API 版本都可以作为 [MCP](https://modelcontextprotocol.io) 服务器,让智能体以工具的方式调用 API: diff --git a/src/Operations/Schema/JsonSchemas.php b/src/Operations/Schema/JsonSchemas.php new file mode 100644 index 0000000..d9c8818 --- /dev/null +++ b/src/Operations/Schema/JsonSchemas.php @@ -0,0 +1,36 @@ + $value) { + if (in_array($key, self::MAPS, true) && $value === []) { + $schema[$key] = new \stdClass(); + } else { + $schema[$key] = self::encodable($value); + } + } + + return $schema; + } +} diff --git a/src/Providers/ApiServiceProvider.php b/src/Providers/ApiServiceProvider.php index 5e3c118..f2153aa 100644 --- a/src/Providers/ApiServiceProvider.php +++ b/src/Providers/ApiServiceProvider.php @@ -16,6 +16,7 @@ use Dskripchenko\LaravelApi\Facades\ApiModule; use Dskripchenko\LaravelApi\Components\BaseModule; use Dskripchenko\LaravelApi\Requests\BaseApiRequest; +use Dskripchenko\LaravelApi\Transports\JsonRpc\JsonRpcController; use Dskripchenko\LaravelApi\Transports\Mcp\McpController; use Exception; use Illuminate\Contracts\Debug\ExceptionHandler; @@ -142,6 +143,24 @@ public static function registerMcpRoutes(): void ->middleware((array) config('laravel-api.mcp.middleware', [])); } + /** + * The JSON-RPC endpoint, when `laravel-api.jsonrpc.enabled` is on — inside + * the API prefix, ahead of the catch-all route. + */ + public static function registerJsonRpcRoutes(): void + { + if (!config('laravel-api.jsonrpc.enabled', false)) { + return; + } + + $path = trim((string) config('laravel-api.jsonrpc.path', 'rpc'), '/'); + + Route::post("{$path}/{version}", static function (string $version) { + return app()->call(JsonRpcController::class . '@handle', ['version' => $version]); + })->name('api-jsonrpc') + ->middleware((array) config('laravel-api.jsonrpc.middleware', [])); + } + public function makeApiRoutes() { $middlewareGroupName = "api-middleware-group"; @@ -170,6 +189,7 @@ public function makeApiRoutes() ->middleware(ApiModule::getDocMiddleware()); static::registerMcpRoutes(); + static::registerJsonRpcRoutes(); $routeDefinitions = ApiModule::getRouteDefinitions(); foreach ($routeDefinitions as $definition) { diff --git a/src/Transports/JsonRpc/JsonRpcController.php b/src/Transports/JsonRpc/JsonRpcController.php new file mode 100644 index 0000000..1cdbb1a --- /dev/null +++ b/src/Transports/JsonRpc/JsonRpcController.php @@ -0,0 +1,47 @@ +getContent(), true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return new JsonResponse(JsonRpcServer::error(null, JsonRpcServer::PARSE_ERROR, 'Parse error: ' . $e->getMessage())); + } + + $server = new JsonRpcServer( + new NamedOperations($apiClass, $version, 'jsonrpc'), + app(Dispatcher::class), + ); + + $answer = $server->handle($message); + + if ($answer === null) { + return new Response('', 204); + } + + return new JsonResponse($answer, 200, [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Transports/JsonRpc/JsonRpcServer.php b/src/Transports/JsonRpc/JsonRpcServer.php new file mode 100644 index 0000000..df2315f --- /dev/null +++ b/src/Transports/JsonRpc/JsonRpcServer.php @@ -0,0 +1,130 @@ +single($message); + } + + if ($message === []) { + return self::error(null, self::INVALID_REQUEST, 'An empty batch.'); + } + + $responses = array_values(array_filter( + array_map(fn (mixed $item): ?array => $this->single($item), $message), + static fn (?array $response): bool => $response !== null + )); + + return $responses === [] ? null : $responses; + } + + private function single(mixed $message): ?array + { + if (!is_array($message) || array_is_list($message) && $message !== [] + || ($message['jsonrpc'] ?? null) !== '2.0' + || !isset($message['method']) || !is_string($message['method']) + ) { + return self::error(is_array($message) ? self::idOf($message) : null, self::INVALID_REQUEST, 'Not a JSON-RPC 2.0 request.'); + } + + $isNotification = !array_key_exists('id', $message); + $id = self::idOf($message); + + $response = $this->call($id, $message['method'], $message['params'] ?? []); + + // A notification is carried out and never answered, not even with an error. + return $isNotification ? null : $response; + } + + private function call(mixed $id, string $method, mixed $params): array + { + if ($method === 'rpc.discover') { + return self::result($id, (new OpenRpcDocument($this->operations))->toArray()); + } + + $operation = $this->operations->find($method); + if ($operation === null) { + return self::error($id, self::METHOD_NOT_FOUND, "Method not found: {$method}"); + } + + if (!is_array($params) || array_is_list($params) && $params !== []) { + return self::error($id, self::INVALID_PARAMS, 'The params have to be an object: they are passed by name.'); + } + + $result = $this->dispatcher->dispatch(new ApiInvocation( + $this->operations->version, + $operation->controllerKey, + $operation->actionKey, + $params, + )); + + return $result->success ? self::result($id, $result->payload) : self::failure($id, $result); + } + + private static function failure(mixed $id, ApiResult $result): array + { + $code = $result->status === 422 ? self::INVALID_PARAMS : self::ACTION_FAILED; + $message = $result->message() ?? $result->errorKey() ?? 'The action failed.'; + + return self::error($id, $code, $message, ['status' => $result->status, 'payload' => $result->payload]); + } + + /** The id of a message, when it is one JSON-RPC allows: a string, a number or null. */ + private static function idOf(array $message): mixed + { + $id = $message['id'] ?? null; + + return is_string($id) || is_int($id) || is_float($id) ? $id : null; + } + + private static function result(mixed $id, mixed $result): array + { + return ['jsonrpc' => '2.0', 'result' => $result, 'id' => $id]; + } + + public static function error(mixed $id, int $code, string $message, mixed $data = null): array + { + $error = ['code' => $code, 'message' => $message]; + if ($data !== null) { + $error['data'] = $data; + } + + return ['jsonrpc' => '2.0', 'error' => $error, 'id' => $id]; + } +} diff --git a/src/Transports/JsonRpc/OpenRpcDocument.php b/src/Transports/JsonRpc/OpenRpcDocument.php new file mode 100644 index 0000000..ad2185b --- /dev/null +++ b/src/Transports/JsonRpc/OpenRpcDocument.php @@ -0,0 +1,134 @@ +operations->apiClass; + $builder = DescriptorBuilder::for($apiClass); + $doc = OperationDocParser::parse((new \ReflectionClass($apiClass))->getDocComment()); + + $methods = []; + foreach ($this->operations->all() as $name => $operation) { + [$descriptor] = $builder->build($operation); + + $method = [ + 'name' => $name, + 'summary' => trim(strtok($descriptor->summary(), "\n") ?: '') ?: $name, + 'paramStructure' => 'by-name', + 'params' => self::params($descriptor->input->schema()), + 'result' => [ + 'name' => 'result', + // An empty schema is `{}` — anything — not a list. + 'schema' => ($descriptor->output?->schema ?? self::explicitResult($descriptor->responses)) + ?: new \stdClass(), + ], + ]; + + $description = trim($descriptor->summary() . "\n\n" . $descriptor->description()); + if ($description !== '') { + $method['description'] = $description; + } + if ($descriptor->deprecated) { + $method['deprecated'] = true; + } + + $methods[] = $method; + } + + $document = [ + 'openrpc' => self::VERSION, + 'info' => [ + 'title' => $doc->summary !== '' ? $doc->summary : $apiClass, + 'version' => $this->operations->version, + ], + 'methods' => $methods, + ]; + + if ($doc->description !== '') { + $document['info']['description'] = $doc->description; + } + + $schemas = SchemaRegistry::for($apiClass)->all(); + if ($schemas !== []) { + $document['components'] = ['schemas' => $schemas]; + } + + return JsonSchemas::encodable($document); + } + + /** + * The fields of the input object, one parameter each. An input named by a + * template as a whole is one parameter carrying its fields — by-name + * params are an object either way. + * + * @return array[] + */ + private static function params(array $schema): array + { + if (isset($schema['$ref'])) { + return [['name' => 'params', 'required' => true, 'schema' => $schema]]; + } + + $required = (array) ($schema['required'] ?? []); + $params = []; + foreach ((array) ($schema['properties'] ?? []) as $name => $property) { + $property = (array) $property; + $param = ['name' => (string) $name]; + + if (isset($property['description']) && $property['description'] !== '') { + $param['description'] = $property['description']; + } + unset($property['description']); + + $param['required'] = in_array($name, $required, true); + $param['schema'] = $property; + $params[] = $param; + } + + return $params; + } + + /** + * With `@response` listing the answers, the result is the template the + * 200 answer carries, if any. + * + * @param \Dskripchenko\LaravelApi\Operations\Descriptor\ResponseDeclaration[] $responses + */ + private static function explicitResult(array $responses): array + { + foreach ($responses as $response) { + if ($response->code === '200' && $response->template !== null) { + return SchemaRef::to($response->template); + } + } + + return []; + } +} diff --git a/src/Transports/Mcp/McpController.php b/src/Transports/Mcp/McpController.php index 914e165..e9b7746 100644 --- a/src/Transports/Mcp/McpController.php +++ b/src/Transports/Mcp/McpController.php @@ -4,9 +4,8 @@ namespace Dskripchenko\LaravelApi\Transports\Mcp; -use Dskripchenko\LaravelApi\Components\BaseApi; -use Dskripchenko\LaravelApi\Facades\ApiModule; use Dskripchenko\LaravelApi\Runtime\Dispatcher; +use Dskripchenko\LaravelApi\Transports\ServedVersions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -74,24 +73,13 @@ public static function flush(): void } /** - * The versions served: `laravel-api.mcp.versions`, or every version the - * documentation lists when that is empty. + * The versions served — {@see ServedVersions}. * * @return array */ public static function servedVersions(): array { - $all = ApiModule::getApiVersionList(); - $chosen = (array) config('laravel-api.mcp.versions', []); - - $versions = $chosen === [] - ? array_diff_key($all, array_flip((array) config('laravel-api.hidden_versions', []))) - : array_intersect_key($all, array_flip($chosen)); - - return array_filter( - $versions, - static fn ($apiClass): bool => is_string($apiClass) && is_subclass_of($apiClass, BaseApi::class) - ); + return ServedVersions::of('mcp'); } /** diff --git a/src/Transports/Mcp/McpToolset.php b/src/Transports/Mcp/McpToolset.php index a43cf94..c0912ce 100644 --- a/src/Transports/Mcp/McpToolset.php +++ b/src/Transports/Mcp/McpToolset.php @@ -6,9 +6,11 @@ use Dskripchenko\LaravelApi\Operations\DescriptorBuilder; use Dskripchenko\LaravelApi\Operations\Operation; +use Dskripchenko\LaravelApi\Operations\Schema\JsonSchemas; use Dskripchenko\LaravelApi\Operations\Schema\SchemaRef; use Dskripchenko\LaravelApi\Operations\Schema\SchemaRegistry; use Dskripchenko\LaravelApi\Services\ApiCache; +use Dskripchenko\LaravelApi\Transports\NamedOperations; /** * The actions of one API version as MCP tools. @@ -20,12 +22,11 @@ * OpenAPI document. * * An action is left out with `'mcp' => false` in its definition, or in its - * controller's. + * controller's ({@see NamedOperations}). */ final class McpToolset { - /** @var array|null Tool name → the operation it calls. */ - private ?array $operations = null; + private readonly NamedOperations $operations; /** @var array[]|null */ private ?array $tools = null; @@ -37,6 +38,7 @@ public function __construct( public readonly string $apiClass, public readonly string $version, ) { + $this->operations = new NamedOperations($apiClass, $version, 'mcp'); } /** @@ -58,38 +60,13 @@ public function tools(): array */ public function find(string $name): ?Operation { - return $this->operations()[$name] ?? null; + return $this->operations->find($name); } - /** `controller.action`, with what a tool name cannot carry replaced by `_`. */ + /** `controller.action` — see {@see NamedOperations::nameOf()}. */ public static function toolName(Operation $operation): string { - return preg_replace('/[^A-Za-z0-9_\-]/', '_', $operation->controllerKey) - . '.' - . preg_replace('/[^A-Za-z0-9_\-]/', '_', $operation->actionKey); - } - - /** @return array */ - private function operations(): array - { - if ($this->operations !== null) { - return $this->operations; - } - - $operations = []; - foreach ($this->apiClass::operations($this->version)->controllers() as $controller) { - if (($controller->options['mcp'] ?? true) === false) { - continue; - } - - foreach ($controller->operations as $operation) { - if (($operation->options['mcp'] ?? true) !== false && $operation->isCallable()) { - $operations[self::toolName($operation)] = $operation; - } - } - } - - return $this->operations = $operations; + return NamedOperations::nameOf($operation); } /** @return array[] */ @@ -99,7 +76,7 @@ private function describe(): array $templates = SchemaRegistry::for($this->apiClass)->all(); $tools = []; - foreach ($this->operations() as $name => $operation) { + foreach ($this->operations->all() as $name => $operation) { [$descriptor] = $builder->build($operation); $description = trim($descriptor->summary() . "\n\n" . $descriptor->description()); @@ -112,7 +89,7 @@ private function describe(): array // The first line: a docblock's second line runs into its summary. 'title' => trim(strtok($descriptor->summary(), "\n") ?: '') ?: $name, 'description' => $description, - 'inputSchema' => self::withDefinitions(self::asObject($descriptor->input->schema()), $templates), + 'inputSchema' => JsonSchemas::encodable(self::withDefinitions(self::asObject($descriptor->input->schema()), $templates)), ]; if (strtolower($descriptor->httpMethod) === 'get') { diff --git a/src/Transports/NamedOperations.php b/src/Transports/NamedOperations.php new file mode 100644 index 0000000..399e2c8 --- /dev/null +++ b/src/Transports/NamedOperations.php @@ -0,0 +1,70 @@ + false` in its + * definition or in its controller's (`'mcp' => false`, `'jsonrpc' => false`). + */ +final class NamedOperations +{ + /** @var array|null */ + private ?array $operations = null; + + /** + * @param class-string $apiClass + * @param string $transport The getMethods() key that leaves an action out. + */ + public function __construct( + public readonly string $apiClass, + public readonly string $version, + public readonly string $transport, + ) { + } + + public static function nameOf(Operation $operation): string + { + return preg_replace('/[^A-Za-z0-9_\-]/', '_', $operation->controllerKey) + . '.' + . preg_replace('/[^A-Za-z0-9_\-]/', '_', $operation->actionKey); + } + + /** @return array Name → operation, in declaration order. */ + public function all(): array + { + if ($this->operations !== null) { + return $this->operations; + } + + $operations = []; + foreach ($this->apiClass::operations($this->version)->controllers() as $controller) { + if (($controller->options[$this->transport] ?? true) === false) { + continue; + } + + foreach ($controller->operations as $operation) { + if (($operation->options[$this->transport] ?? true) !== false && $operation->isCallable()) { + $operations[self::nameOf($operation)] = $operation; + } + } + } + + return $this->operations = $operations; + } + + public function find(string $name): ?Operation + { + return $this->all()[$name] ?? null; + } +} diff --git a/src/Transports/ServedVersions.php b/src/Transports/ServedVersions.php new file mode 100644 index 0000000..faffb9e --- /dev/null +++ b/src/Transports/ServedVersions.php @@ -0,0 +1,34 @@ + Version → Api class. + */ + public static function of(string $transport): array + { + $all = ApiModule::getApiVersionList(); + $chosen = (array) config("laravel-api.{$transport}.versions", []); + + $versions = $chosen === [] + ? array_diff_key($all, array_flip((array) config('laravel-api.hidden_versions', []))) + : array_intersect_key($all, array_flip($chosen)); + + return array_filter( + $versions, + static fn ($apiClass): bool => is_string($apiClass) && is_subclass_of($apiClass, BaseApi::class) + ); + } +} diff --git a/tests/Feature/Transports/JsonRpcTest.php b/tests/Feature/Transports/JsonRpcTest.php new file mode 100644 index 0000000..65ad641 --- /dev/null +++ b/tests/Feature/Transports/JsonRpcTest.php @@ -0,0 +1,207 @@ + array_merge(config('laravel-api.jsonrpc'), ['enabled' => true], $config)]); + Route::prefix(ApiModule::getApiPrefix())->group(static fn () => ApiServiceProvider::registerJsonRpcRoutes()); + Route::getRoutes()->refreshNameLookups(); +} + +function rpc(object $test, mixed $body, array $headers = [], string $version = 'v1'): TestResponse +{ + return $test->postJson("/api/rpc/{$version}", $body, $headers); +} + +function request_(string $method, array $params = [], int|string|null $id = 1): array +{ + $message = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; + if ($id !== null) { + $message['id'] = $id; + } + + return $message; +} + +it('is off unless enabled', function () { + rpc($this, request_('open.ping'))->assertNotFound(); +}); + +it('calls an action by name and answers with its payload', function () { + enableJsonRpc(); + + rpc($this, request_('open.ping')) + ->assertOk() + ->assertExactJson(['jsonrpc' => '2.0', 'result' => ['pong' => true], 'id' => 1]); +}); + +it('passes the params by name, and the headers of the request', function () { + enableJsonRpc(); + + rpc($this, request_('item.create', ['name' => 'New'], 'a'), ['X-Auth-Token' => 'token']) + ->assertExactJson(['jsonrpc' => '2.0', 'result' => ['id' => 1, 'name' => 'New'], 'id' => 'a']); +}); + +it('answers a failed action with an error carrying the status and the payload', function () { + enableJsonRpc(); + + rpc($this, request_('item.list'))->assertExactJson([ + 'jsonrpc' => '2.0', + 'error' => [ + 'code' => JsonRpcServer::ACTION_FAILED, + 'message' => 'Token required', + 'data' => ['status' => 200, 'payload' => ['errorKey' => 'auth_error', 'message' => 'Token required']], + ], + 'id' => 1, + ]); +}); + +it('answers protocol mistakes with the codes JSON-RPC defines', function () { + enableJsonRpc(); + + expect(rpc($this, request_('item.nope'))->json('error.code'))->toBe(JsonRpcServer::METHOD_NOT_FOUND); + expect(rpc($this, request_('open.ping', ['positional']))->json('error.code'))->toBe(JsonRpcServer::INVALID_PARAMS); + expect(rpc($this, ['jsonrpc' => '1.0', 'id' => 1, 'method' => 'open.ping'])->json('error.code'))->toBe(JsonRpcServer::INVALID_REQUEST); + expect(rpc($this, [])->json('error.code'))->toBe(JsonRpcServer::INVALID_REQUEST); + + $parse = $this->call('POST', '/api/rpc/v1', [], [], [], ['CONTENT_TYPE' => 'application/json'], '{nope'); + $parse->assertOk(); + expect($parse->json())->toMatchArray(['jsonrpc' => '2.0', 'id' => null]); + expect($parse->json('error.code'))->toBe(JsonRpcServer::PARSE_ERROR); +}); + +it('answers a batch in order, leaving the notifications out', function () { + enableJsonRpc(); + + $response = rpc($this, [ + request_('open.ping', [], 1), + request_('open.ping', [], null), + request_('item.nope', [], 2), + 42, + ]); + + expect($response->json())->toBe([ + ['jsonrpc' => '2.0', 'result' => ['pong' => true], 'id' => 1], + ['jsonrpc' => '2.0', 'error' => ['code' => JsonRpcServer::METHOD_NOT_FOUND, 'message' => 'Method not found: item.nope'], 'id' => 2], + ['jsonrpc' => '2.0', 'error' => ['code' => JsonRpcServer::INVALID_REQUEST, 'message' => 'Not a JSON-RPC 2.0 request.'], 'id' => null], + ]); +}); + +it('answers nothing to notifications alone', function () { + enableJsonRpc(); + + rpc($this, request_('open.ping', [], null))->assertNoContent(); + rpc($this, [request_('open.ping', [], null), request_('open.ping', [], null)])->assertNoContent(); +}); + +it('serves the versions it is told to', function () { + enableJsonRpc(['versions' => ['v2']]); + + rpc($this, request_('open.ping'))->assertNotFound(); + rpc($this, request_('open.ping'), version: 'v2')->assertOk(); +}); + +it('describes the version as an OpenRPC document', function () { + enableJsonRpc(); + + $document = rpc($this, request_('rpc.discover'))->json('result'); + + expect($document['openrpc'])->toBe('1.3.2'); + expect($document['info'])->toBe(['title' => 'Test API v1', 'version' => 'v1', 'description' => 'Test API description']); + + $create = collect($document['methods'])->firstWhere('name', 'item.create'); + expect($create['summary'])->toBe('Create item'); + expect($create['paramStructure'])->toBe('by-name'); + expect(collect($create['params'])->firstWhere('name', 'name'))->toBe([ + 'name' => 'name', 'description' => 'Item name', 'required' => true, 'schema' => ['type' => 'string'], + ]); + expect($create['result']['name'])->toBe('result'); + + expect(array_column($document['methods'], 'name'))->toContain('open.ping', 'item.list'); +}); + +// --- A validation failure ---------------------------------------------------- + +class JsonRpcValidatingController extends ApiController +{ + /** + * Register + * + * @input string $email Email + */ + public function register(Request $request): JsonResponse + { + $request->validate(['email' => 'required|email']); + + return $this->success(['email' => $request->input('email')]); + } +} + +class JsonRpcValidatingApi extends BaseApi +{ + public static function getMethods(): array + { + return ['controllers' => [ + 'user' => ['controller' => JsonRpcValidatingController::class, 'actions' => ['register']], + 'hidden' => ['controller' => JsonRpcValidatingController::class, 'jsonrpc' => false, 'actions' => ['register']], + ]]; + } +} + +class JsonRpcValidatingModule extends BaseModule +{ + public function getApiVersionList(): array + { + return ['v1' => JsonRpcValidatingApi::class]; + } +} + +it('answers a validation failure as invalid params', function () { + app()->bind('api_module', static fn () => new JsonRpcValidatingModule()); + ApiModule::clearResolvedInstance('api_module'); + (new ApiServiceProvider(app()))->makeApiRoutes(); + enableJsonRpc(); + + $failed = rpc($this, request_('user.register', ['email' => 'not-an-email'])); + expect($failed->json('error.code'))->toBe(JsonRpcServer::INVALID_PARAMS); + expect($failed->json('error.data.status'))->toBe(422); + expect($failed->json('error.data.payload.errors'))->toHaveKey('email'); + + expect(rpc($this, request_('user.register', ['email' => 'a@b.c']))->json('result'))->toBe(['email' => 'a@b.c']); + + // Left out with 'jsonrpc' => false. + expect(rpc($this, request_('hidden.register', ['email' => 'a@b.c']))->json('error.code'))->toBe(JsonRpcServer::METHOD_NOT_FOUND); +}); + +it('writes empty maps of a schema as JSON objects', function () { + $schema = \Dskripchenko\LaravelApi\Operations\Schema\JsonSchemas::encodable([ + 'type' => 'object', + 'properties' => [], + 'required' => [], + 'items' => ['properties' => ['a' => ['type' => 'object', 'properties' => []]]], + '$defs' => [], + ]); + + expect(json_encode($schema))->toBe('{"type":"object","properties":{},"required":[],"items":{"properties":{"a":{"type":"object","properties":{}}}},"$defs":{}}'); +}); + +it('describes an action without output fields with a valid result schema', function () { + $document = (new \Dskripchenko\LaravelApi\Transports\JsonRpc\OpenRpcDocument( + new \Dskripchenko\LaravelApi\Transports\NamedOperations(\Tests\Fixtures\OpenApi\ContextApi::class, 'v1', 'jsonrpc') + ))->toArray(); + + // A schema validator rejects `"properties": []`. + expect(json_encode($document))->not->toContain('"properties":[]'); +});