DEV Community

Cover image for Build a JSON-RPC 2.0 API in Symfony in 15 minutes: from composer require to OpenAPI
OtezVikentiy
OtezVikentiy

Posted on

Build a JSON-RPC 2.0 API in Symfony in 15 minutes: from composer require to OpenAPI

REST works great while your API describes resources. But as soon as the domain becomes verb-shaped - recalculateInvoice, mergeAccounts, assignTask - you end up bending verbs into nouns and arguing about which HTTP method cancels an order. JSON-RPC 2.0 cuts through all of that: every call is just method + params, one endpoint, a spec that fits on two pages, and batching out of the box.

In this article we will build a working JSON-RPC 2.0 API on Symfony: a task tracker with DTO validation, batch requests and generated OpenAPI documentation. There is surprisingly little code to write: methods are declared with attributes, validation is derived from property types, and Swagger is generated by a console command.

Everything below lives as a ready-to-run project on GitHub: symfony-jsonrpc-api-demo - clone it and poke it with curl while you read. We will use the otezvikentiy/json-rpc-api bundle (PHP 8.2-8.5, Symfony 6.4/7/8; this article uses PHP 8.4 and Symfony 7.4).

Full disclosure: I am the author of the bundle. It has been running in production for three years - internal fintech tooling, an HRM system - nothing glamorous load-wise, but the correctness, logging and audit requirements were real, and they shaped most of what you will see below.

Installation

composer create-project symfony/skeleton:"7.4.*" tasks-api
cd tasks-api
composer require otezvikentiy/json-rpc-api
Enter fullscreen mode Exit fullscreen mode

If Flex has contrib recipes enabled, the bundle registers itself. If not, it is two lines by hand:

// config/bundles.php
return [
    // ...
    OV\JsonRPCAPIBundle\OVJsonRPCAPIBundle::class => ['all' => true],
];
Enter fullscreen mode Exit fullscreen mode

Wire up the route and a minimal config:

# config/routes/ov_json_rpc_api.yaml
ov_json_rpc_api:
    resource: '@OVJsonRPCAPIBundle/config/routes/routes.yaml'
Enter fullscreen mode Exit fullscreen mode
# config/packages/ov_json_rpc_api.yaml
ov_json_rpc_api:
    access_control_allow_origin_list:
        - 'http://localhost:8000'
Enter fullscreen mode Exit fullscreen mode

The bundle registers a single route, /api/v{version} - every request goes through it. Note the CORS list format: these are full origins, scheme://host[:port], exactly as the browser sends them in the Origin header.

Check that it is alive:

php -S localhost:8000 -t public
curl -s -X POST http://localhost:8000/api/v1 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"test","params":{},"id":1}'
Enter fullscreen mode Exit fullscreen mode
{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found."},"id":1}
Enter fullscreen mode Exit fullscreen mode

A well-formed JSON-RPC "method not found" error means the transport works. Now let's add methods.

The first method: createTask

A method in this bundle is three classes: a Request (what comes in), a Response (what goes out) and the method itself carrying the #[JsonRPCAPI] attribute. No YAML manifests, no base controllers to extend.

The Request describes the parameters. The rule is simple: constructor parameters are required, properties with setters are optional:

// src/RPC/V1/CreateTask/CreateTaskRequest.php
declare(strict_types=1);

namespace App\RPC\V1\CreateTask;

final class CreateTaskRequest
{
    private ?string $assigneeEmail = null;

    public function __construct(private string $title)
    {
    }

    public function getTitle(): string
    {
        return $this->title;
    }

    public function setTitle(string $title): void
    {
        $this->title = $title;
    }

    public function getAssigneeEmail(): ?string
    {
        return $this->assigneeEmail;
    }

    public function setAssigneeEmail(?string $assigneeEmail): void
    {
        $this->assigneeEmail = $assigneeEmail;
    }
}
Enter fullscreen mode Exit fullscreen mode

The Response is a plain class; whatever the class makes public - a public getter or a public property - ends up in the JSON. Promoted constructor properties keep it short:

// src/RPC/V1/CreateTask/CreateTaskResponse.php
declare(strict_types=1);

namespace App\RPC\V1\CreateTask;

final class CreateTaskResponse
{
    public function __construct(
        public readonly bool $success,
        public readonly int $id,
        public readonly string $title,
        public readonly string $status,
        public readonly ?string $assigneeEmail,
    ) {
    }
}
Enter fullscreen mode Exit fullscreen mode

And the method itself - an ordinary autowired service:

// src/RPC/V1/CreateTaskMethod.php
declare(strict_types=1);

namespace App\RPC\V1;

use App\RPC\V1\CreateTask\CreateTaskRequest;
use App\RPC\V1\CreateTask\CreateTaskResponse;
use App\Task\TaskStorage;
use OV\JsonRPCAPIBundle\Core\Annotation\JsonRPCAPI;
use OV\JsonRPCAPIBundle\Core\ApiMethodInterface;

#[JsonRPCAPI(
    methodName: 'createTask',
    type: 'POST',
    summary: 'Create a task',
    tags: ['tasks'],
)]
final class CreateTaskMethod implements ApiMethodInterface
{
    public function __construct(private readonly TaskStorage $storage)
    {
    }

    public function call(CreateTaskRequest $request): CreateTaskResponse
    {
        $task = $this->storage->create($request->getTitle(), $request->getAssigneeEmail());

        return new CreateTaskResponse(true, $task->id, $task->title, $task->status->value, $task->assigneeEmail);
    }
}
Enter fullscreen mode Exit fullscreen mode

TaskStorage here is a trivial JSON-file store (about 80 lines in the demo repo; in a real project a Doctrine repository takes its place - the method contract does not change). The API version is derived from the namespace: App\RPC\V1 -> /api/v1.

Call it:

curl -s -X POST http://localhost:8000/api/v1 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"createTask","params":{"title":"Try the demo","assigneeEmail":"alice@example.com"},"id":1}'
Enter fullscreen mode Exit fullscreen mode
{"jsonrpc":"2.0","result":{"success":true,"id":1,"title":"Try the demo","status":"todo","assigneeEmail":"alice@example.com"},"id":1}
Enter fullscreen mode Exit fullscreen mode

Validation you don't have to write

The best part: the bundle builds the validator set itself, from the PHP types of the Request class. private string $title in the constructor means "required, string". ?string $assigneeEmail with a setter means "optional, string or null". Since version 5.0 the type comparison is strict - no silent coercion:

curl -s -X POST http://localhost:8000/api/v1 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"createTask","params":{"title":123},"id":4}'
Enter fullscreen mode Exit fullscreen mode
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params. Additional info: [title] - This value should be of type string."},"id":4}
Enter fullscreen mode Exit fullscreen mode

"123" will not quietly become a number, 1 will not become true - the client gets an immediate -32602 Invalid params with a readable explanation. Your request contract is literally the PHP types of your DTO.

Domain errors: JRPCException

For errors like "task not found" the bundle ships JRPCException - a thrown exception becomes a proper JSON-RPC error object:

// src/RPC/V1/GetTaskMethod.php (excerpt)
public function call(GetTaskRequest $request): GetTaskResponse
{
    $task = $this->storage->find($request->getId());
    if ($task === null) {
        throw new JRPCException('Task not found.', JRPCException::SERVER_ERROR);
    }

    return new GetTaskResponse(true, $task->id, $task->title, $task->status->value, $task->assigneeEmail);
}
Enter fullscreen mode Exit fullscreen mode
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Task not found."},"id":1}
Enter fullscreen mode Exit fullscreen mode

The spec-defined codes (-32700...-32603) and the server range [-32099, -32000] are validated by the exception itself - you cannot accidentally invent an invalid code. Everything else - any unexpected Throwable - reaches the client as a generic -32603 Internal error, while the full stack trace goes to the log only. No leaking file paths or class names to the outside world.

Batches: N calls, one HTTP request

This is a protocol feature rather than a bundle feature, but here it works out of the box - send an array of requests instead of a single object:

curl -s -X POST http://localhost:8000/api/v1 \
  -H "Content-Type: application/json" \
  -d '[{"jsonrpc":"2.0","method":"createTask","params":{"title":"One"},"id":1},
       {"jsonrpc":"2.0","method":"createTask","params":{"title":"Two"},"id":2},
       {"jsonrpc":"2.0","method":"listTasks","params":{},"id":3}]'
Enter fullscreen mode Exit fullscreen mode

The response is an array of three results, matched by id. Where a REST client makes N round-trips (and the frontend shows N spinners), this is one request. The maximum batch size is capped by config (max_batch_size), so a million-call batch DoS does not get through.

OpenAPI from the same source of truth

The method attributes and DTO types are the single source of truth - and the OpenAPI 3.1 document is generated from them:

# config/packages/ov_json_rpc_api.yaml (add this)
ov_json_rpc_api:
    # ...
    swagger:
        api_v1:
            api_version: '1'
            base_path: 'http://localhost:8000'
            info:
                title: 'Tasks JSON-RPC API'
                description: 'Task tracker built on JSON-RPC 2.0'
Enter fullscreen mode Exit fullscreen mode
# .env
OV_JSON_RPC_API_SWAGGER_PATH=public/openapi/

php bin/console ov:swagger:generate
Enter fullscreen mode Exit fullscreen mode

The output is public/openapi/api_v1.yaml with schemas for every Request/Response - ready for Swagger UI, Postman or client SDK generation. The docs cannot drift away from the code, because they are made from it.

What else is in the demo

The demo repo takes the same application further, and you can see the rest working there:

  • the full CRUD - getTask, listTasks with a status filter, updateTask, deleteTask;
  • API versioning: a paginated listTasks v2 lives at /api/v2 without touching v1 - it is just a second namespace, App\RPC\V2;
  • sane default limits: body size, JSON depth, batch size - all configurable, all covered by tests;
  • request logging with masking of sensitive fields by regex patterns (29 built-in patterns: password, token, card_number, ...);
  • functional tests through a regular WebTestCase - 26 tests over the real endpoint.

Each of those deserves its own write-up - tell me in the comments which one to start with.

Links

Feedback is welcome - including the harsh kind. This project has learned a lot from it.

Top comments (0)