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
2 changes: 2 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions config/laravel-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
25 changes: 25 additions & 0 deletions docs/de/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions docs/ru/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)-сервером —
Expand Down
17 changes: 17 additions & 0 deletions docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions src/Operations/Schema/JsonSchemas.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Dskripchenko\LaravelApi\Operations\Schema;

/**
* Schemas made ready for json_encode().
*
* PHP has one array for lists and maps, and json_encode() writes an empty one
* as `[]` — a list. Where JSON Schema expects a map — `properties`, `$defs` —
* that is not an empty map but a type error, and a validator says so.
*/
final class JsonSchemas
{
/** The keywords whose value is a map from names to schemas. */
private const MAPS = ['properties', 'patternProperties', '$defs', 'definitions'];

/** The schema with every empty map written as `{}`, at any depth. */
public static function encodable(mixed $schema): mixed
{
if (!is_array($schema)) {
return $schema;
}

foreach ($schema as $key => $value) {
if (in_array($key, self::MAPS, true) && $value === []) {
$schema[$key] = new \stdClass();
} else {
$schema[$key] = self::encodable($value);
}
}

return $schema;
}
}
20 changes: 20 additions & 0 deletions src/Providers/ApiServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -170,6 +189,7 @@ public function makeApiRoutes()
->middleware(ApiModule::getDocMiddleware());

static::registerMcpRoutes();
static::registerJsonRpcRoutes();

$routeDefinitions = ApiModule::getRouteDefinitions();
foreach ($routeDefinitions as $definition) {
Expand Down
47 changes: 47 additions & 0 deletions src/Transports/JsonRpc/JsonRpcController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Dskripchenko\LaravelApi\Transports\JsonRpc;

use Dskripchenko\LaravelApi\Runtime\Dispatcher;
use Dskripchenko\LaravelApi\Transports\NamedOperations;
use Dskripchenko\LaravelApi\Transports\ServedVersions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
* The JSON-RPC 2.0 endpoint of an API version: a request or a batch POSTed as
* JSON, the answer as JSON — 200 whatever the outcome, the outcome being in
* the body, and 204 when everything posted was a notification.
*/
class JsonRpcController
{
public function handle(Request $request, string $version): Response
{
$apiClass = ServedVersions::of('jsonrpc')[$version] ?? null;
if ($apiClass === null) {
return new JsonResponse(JsonRpcServer::error(null, JsonRpcServer::INVALID_REQUEST, "No JSON-RPC endpoint for version {$version}."), 404);
}

try {
$message = json_decode((string) $request->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);
}
}
Loading
Loading