phloxcz / rpc-api
JSON-RPC 2.0 server for Nette: API controllers as DI services, with attribute-based authorization and typed parameters.
Requires
- php: >=8.4
- nette/di: ^3.1
- nette/http: ^3.2
- nette/schema: ^1.3
- nette/utils: ^4.0
- phloxcz/api-core: ^0.2@dev
Requires (Dev)
- nette/application: ^3.2
- nette/bootstrap: ^3.2
- nette/tester: ^2.5
- phpstan/phpstan: ^2.0
- tracy/tracy: ^2.10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-07 18:24:05 UTC
README
English | Česky
JSON-RPC 2.0 server for Nette.
API controllers are ordinary DI services. Their methods, authorization and parameters are described by PHP attributes and native types, and the method table is compiled into the DI container, so nothing is reflected at runtime.
⚠️ This package is under active development. The API is not yet stable.
Requirements
PHP 8.4+ and phloxcz/api-core, which documents controllers, authorization and parameter types.
Installation
composer require phloxcz/rpc-api
extensions: rpcApi: Phlox\RpcApi\DI\Extension rpcApi: endpoints: api: # served at /api namespace: App\ApiModule # string or list; includes sub-namespaces authorize: true # default for methods without an attribute maxBatchSize: 100 accessControl: allowedOrigins: ['https://app.example.com'] allowCredentials: true services: - App\ApiModule\UserController
Usage
namespace App\ApiModule; use Phlox\ApiCore\ApiController; class UserController extends ApiController { public function read(int $id): array { return ['id' => $id]; } }
POST /api
{"jsonrpc": "2.0", "method": "user.read", "params": {"id": 1}, "id": 1}
{"jsonrpc": "2.0", "result": {"id": 1}, "id": 1}
Parameters can be named or positional. Names must match exactly: user.read, not user/read or userread.
Method names
UserController::read() is user.read. #[Path] overrides either part, and two methods with the same name on one endpoint fail when the container compiles:
use Phlox\RpcApi\Attributes\Path; #[Path('admin.user')] class UserController extends ApiController { #[Path('fetch')] public function read(): array { /* admin.user.fetch */ } }
Protocol
- A request without an
idmember is a notification: it runs, and nothing is sent back, not even an error."id": nullis an ordinary request. - A batch larger than
maxBatchSizeis refused as a whole. Within a batch, a failing call doesn't affect the others. - Protocol errors use the standard codes
-32700,-32600,-32601,-32602and-32603, seeProtocolError. Authorization errors use401and403. - An
ApiExceptiongives itserrorCode, or its HTTP status without one:new ApiException(404, 'Not found.')is code404. The status can be left out:new ApiException(errorCode: 1001, message: 'Closed.').#[Throws]documents the same code, and takes the same. dataof anApiExceptionis merged straight into theerrorobject, next tocodeandmessage.- The HTTP status is
200for anything with a body and204when there's none.
Requests other than POST and OPTIONS to an endpoint, and all requests elsewhere, fall through to the application.
DTOs
A parameter can be a DTO, and a method can return one. See DTOs.
public function save(CreateUser $input): UserOutput { /* ... */ }
{"jsonrpc": "2.0", "method": "user.save", "params": {"input": {"name": "Ann"}}, "id": 1}
Invalid data gets code -32602 with all problems in fields, such as input.name.
OpenRPC
Every endpoint gets an OpenRPC document, generated when the container compiles from the same methods the server runs on. In debug mode it's published as the method rpc.discover, which is how the specification finds it, and for GET at /rpc/openrpc.json, which tools read. /rpc/openrpc is a page opening it in OpenRPC Playground, which shows the methods, their parameters and results. Playground reads the document from your browser, so it reaches a server on your computer as well; on a server elsewhere, accessControl has to allow https://playground.open-rpc.org. What it's made of is described in phloxcz/api-core.
rpcApi: endpoints: api: namespace: App\RpcModule documentation: expose: true # by default only in debug mode info: {title: Orders API, version: 2.1.0} path: openrpc # gives /rpc/openrpc.json and the page at /rpc/openrpc servers: # OpenRPC needs an absolute URL, so it's left out by default - {name: production, url: 'https://api.example.com/api'}
The result comes from the native return type, or from #[QueryOf]. Where that says too little, such as array, #[Result] says it, with an object of Phlox\ApiCore\Schema. A docblock @return isn't read.
use Phlox\ApiCore\Schema\{ListOf, Shape}; use Phlox\RpcApi\Attributes\Result; #[Result(new Shape(['ids' => new ListOf('int')]), description: 'IDs of the new orders')] public function import(array $orders): array { /* ... */ }
Authorization has no place in OpenRPC, so a method requiring login carries x-authorize and x-roles, with the same in its description and errors 401 and 403. While the document is published, a method of your own named rpc.discover fails when the container compiles.
Testing that results match it
OpenRpcChecker checks a result against the document, so a test notices the documentation drifting from what the API returns:
use Phlox\RpcApi\Testing\OpenRpcChecker; $checker = OpenRpcChecker::of($container->getByType(OpenRpc::class), 'rpc'); Assert::same([], $checker->check('stats.tasks', $response['result']));
It reports a method the document doesn't have, and every value not matching the schema, such as rpc stats.tasks: $.data[0].id: "3" is not integer.
CORS
Without allowedOrigins, no CORS headers are sent. An origin not on the list gets none either. ['*'] sends a literal *; combining it with allowCredentials: true fails when the container compiles, because browsers refuse it. A client sending an Authorization header needs no credentials here; for cookies, list the origins.
Configuration
| Key | Default | Description |
|---|---|---|
debug |
%debugMode% |
Adds internal messages to error responses |
dateTimeFormats |
see ParameterBinder |
Accepted formats of date parameters |
endpoints.<name>.namespace |
required | Controllers served by the endpoint |
endpoints.<name>.authorize |
true |
Whether methods without an attribute require login |
endpoints.<name>.maxBatchSize |
100 |
Maximum calls in one batch |
endpoints.<name>.accessControl |
no CORS | allowedOrigins, allowCredentials, allowMethods, allowHeaders, exposeHeaders, maxAge |
endpoints.<name>.documentation |
generated, published in debug mode | see OpenRPC |
Migrating from abrad450/api-server-nette
The whole way is in MIGRATION.md. What concerns this package:
Namespaces:
| Old | New |
|---|---|
AbraD450\ApiServer\ApiController |
Phlox\ApiCore\ApiController |
AbraD450\ApiServer\Common\Attributes\* |
Phlox\ApiCore\Attributes\* |
AbraD450\ApiServer\RPC\Attributes\Path |
Phlox\RpcApi\Attributes\Path |
AbraD450\ApiServer\RPC\ApiException |
Phlox\ApiCore\ApiException |
AbraD450\ApiServer\ApiConstructible |
Phlox\ApiCore\ApiConstructible |
Changes that need checking in an existing project:
- Config:
apiServer: rpc: <name>becomesrpcApi: endpoints: <name>. - Authorization is on by default. Mark public methods with
#[AllowAnonymous], or setauthorize: falseon the endpoint. #[Authorize(['a', 'b'])]now means a or b, so it lets in more users than before. Where both roles are needed, write#[Authorize(['a', 'b'], all: true)]. Attributes on a class and a method still have to hold together.- Inherited methods are no longer exposed. A method from a shared base controller has to be overridden in the concrete controller.
#[NonApi]now works in RPC. Methods carrying it were callable before.- CORS:
allowedOrigins: ['*']withallowCredentials: truemust become a list of origins. - Method names match exactly. Variants such as
user/readno longer resolve. - Stricter parameters:
"1.5"forintandtrueforstringare refused. - Stricter protocol: failed notifications no longer send an error, and
"id": nullis no longer a notification. - The bootstrap runs before the API. Code after
createContainer()now also runs for API requests, including anything like a redirect or a maintenance mode.index.phphas to callApplication::run(). - Dates and times: a time zone alone, such as
xorutc, is now refused instead of meaning the current time. - Results are described by
#[Result], not by@return. A method returningarraywithout it is documented as any array, see OpenRPC. - The query language moved to
phloxcz/api-query, including$this->processor.