otezvikentiy/json-rpc-api

Symfony bundle for building JSON-RPC 2.0 APIs: attribute-driven methods, DTO validation, batch requests, API versioning, OpenAPI/Swagger generation and hardened security defaults

Maintainers

Package info

github.com/OtezVikentiy/symfony-jsonrpc-api-bundle

Documentation

Type:symfony-bundle

pkg:composer/otezvikentiy/json-rpc-api

Transparency log

Statistics

Installs: 335

Dependents: 0

Suggesters: 0

Stars: 5

Open Issues: 2

5.2-stable 2026-08-17 20:31 UTC

README

English · Русский

CI Latest Stable Version PHP Version Symfony Version License: MIT Coverage

A Symfony bundle for fast and convenient creation of JSON-RPC 2.0 API applications.

GitHub: https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle

Design Goals

  • Strict JSON-RPC 2.0 compliance. Requests, batches, notifications, error objects and id semantics follow the specification precisely, and the test suite pins that behaviour down.
  • Attribute-driven, DTO-first. An API method is a single class with a #[JsonRPCAPI] attribute; its request and response are plain PHP objects with typed properties. No per-method routing configuration, no schema files to keep in sync with the code.
  • Safe defaults. Payload, nesting and batch limits, error sanitization, CORS whitelisting and log masking work out of the box; every deliberate trade-off is written down in SECURITY.md.
  • Documentation from the same source of truth. OpenAPI/Swagger documentation is generated from the same attributes and DTOs the runtime uses, so it cannot drift away from the actual behaviour.
  • Small dependency footprint. Only symfony/* components and psr/log — nothing else is pulled into your project.
  • Every supported combination is tested. CI runs the full matrix of PHP 8.2–8.5 against Symfony 6.4, 7 and 8, plus a lowest-dependencies build, a coverage gate and a mutation-testing gate.

Want to see all of this working together? Check the demo application: symfony-jsonrpc-api-demo — a small task-tracker API showcasing attributes, DTO validation, batches, API versioning, OpenAPI generation and log masking.

Features

  • Full JSON-RPC 2.0 specification compliance
  • Method configuration via PHP 8 attributes (#[JsonRPCAPI(...)])
  • HTTP methods support: POST, GET, PUT, PATCH, DELETE
  • API versioning (/api/v1, /api/v2, ...)
  • Automatic OpenAPI/Swagger documentation generation
  • Pre- and Post-processors (middleware)
  • Batch requests
  • Built-in request validation
  • Role-based access control via Symfony Security
  • Binary response support (images, documents)
  • File uploads over multipart/form-data (opt-in, off by default)

Requirements

  • PHP 8.2 – 8.5
  • Symfony ^6.4 || ^7.0 || ^8.0

Installation

composer require otezvikentiy/json-rpc-api

With Symfony Flex that is the whole installation: the official contrib recipe registers the bundle, creates config/packages/ov_json_rpc_api.yaml and config/routes/ov_json_rpc_api.yaml, and adds the environment variables to .env. Flex asks for confirmation before executing a contrib recipe; to pre-approve them, run composer config extra.symfony.allow-contrib true once. Review the generated config and adjust it to your project.

Without Flex, configure everything manually. Enable the bundle:

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

Create configuration files:

# config/routes/ov_json_rpc_api.yaml
ov_json_rpc_api:
    resource: '@OVJsonRPCAPIBundle/config/routes/routes.yaml'
# config/packages/ov_json_rpc_api.yaml
ov_json_rpc_api:
    access_control_allow_origin_list:
        - '*'
    swagger:
        api_v1:
            api_version: '1'
            base_path: '%env(string:OV_JSON_RPC_API_BASE_URL)%'
            base_path_description: 'Production server'
            test_path: '%env(string:OV_JSON_RPC_API_TEST_URL)%'
            test_path_description: 'Sandbox server'
            auth_token_name: 'X-AUTH-TOKEN'
            auth_token_test_value: '%env(string:OV_JSON_RPC_API_AUTH_TOKEN)%'
            info:
                title: 'My API'
                description: 'JSON-RPC 2.0 API'
                terms_of_service_url: 'https://example.com/tos'
                contact:
                    name: 'Support'
                    url: 'https://example.com'
                    email: 'support@example.com'
                license: 'MIT'
                licenseUrl: 'https://opensource.org/licenses/MIT'
# .env
OV_JSON_RPC_API_SWAGGER_PATH=public/openapi/
OV_JSON_RPC_API_BASE_URL=http://localhost
OV_JSON_RPC_API_TEST_URL=http://localhost
OV_JSON_RPC_API_AUTH_TOKEN=your_test_token_here

Detailed instructions: docs/installation.md

Quick Start

1. Create a Request

// src/RPC/V1/GetProduct/Request.php
namespace App\RPC\V1\GetProduct;

class Request
{
    private int $id;
    private string $title;

    public function __construct(int $id)
    {
        $this->id = $id;
    }

    public function getId(): int { return $this->id; }
    public function setId(int $id): void { $this->id = $id; }
    public function getTitle(): string { return $this->title; }
    public function setTitle(string $title): void { $this->title = $title; }
}

2. Create a Response

// src/RPC/V1/GetProduct/Response.php
namespace App\RPC\V1\GetProduct;

class Response
{
    private bool $success;
    private string $title;
    private int $price;

    public function __construct(bool $success = true)
    {
        $this->success = $success;
    }

    public function isSuccess(): bool { return $this->success; }
    public function setSuccess(bool $success): void { $this->success = $success; }
    public function getTitle(): string { return $this->title; }
    public function setTitle(string $title): void { $this->title = $title; }
    public function getPrice(): int { return $this->price; }
    public function setPrice(int $price): void { $this->price = $price; }
}

3. Create an API Method

// src/RPC/V1/GetProductMethod.php
namespace App\RPC\V1;

use OV\JsonRPCAPIBundle\Core\Annotation\JsonRPCAPI;
use OV\JsonRPCAPIBundle\Core\ApiMethodInterface;
use App\RPC\V1\GetProduct\Request;
use App\RPC\V1\GetProduct\Response;

#[JsonRPCAPI(methodName: 'getProduct', type: 'POST')]
class GetProductMethod implements ApiMethodInterface
{
    public function call(Request $request): Response
    {
        $response = new Response();
        $response->setTitle('Iphone 15');
        $response->setPrice(2000);
        return $response;
    }
}

4. Call the API

curl -X POST http://localhost/api/v1 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "method": "getProduct", "params": {"id": 1, "title": "test"}, "id": "1"}'

Response:

{
    "jsonrpc": "2.0",
    "result": {
        "success": true,
        "title": "Iphone 15",
        "price": 2000
    },
    "id": "1"
}

Architecture

Request Processing Pipeline

HTTP POST /api/v{version}
    |
    v
ApiController
    |
    v
RequestRawDataHandler --- parses HTTP request (JSON body / query params)
    |
    v
BatchStrategyFactory --- determines: single or batch request
    |
    v
RequestHandler
    |--- Lookup MethodSpec by method name
    |--- Create Request object from parameters
    |--- Validate typed properties
    |--- PreProcessors (if any)
    |--- Method::call(Request) -> Response
    |--- PostProcessors (if any)
    |
    v
ResponseService --- serializes response into JSON-RPC 2.0 format

API Method Project Structure

src/RPC/V1/
    GetProductMethod.php          # Method class with #[JsonRPCAPI] attribute
    GetProduct/
        Request.php               # Incoming request DTO
        Response.php              # Response DTO

Classes marked with the #[JsonRPCAPI] attribute are automatically discovered and registered by the bundle.

Examples

Example Description Files
Basic Simplest example of creating an API method Request, Response, Method
Pre/Post-processors Executing logic before and after method call Request, Response, Method, AbstractMethod
Array of objects Returning a collection of objects in the response Request, Response, Method, Product
Binary response Returning images, documents and other binary data Request, PlainResponse, Method

Additional Documentation

Section Description
Error Handling Error codes, JRPCException, custom errors, additionalInfo
Notification Requests Requests without id, strict_notifications parameter
Parameter Validation Automatic type validation, nullable, error format
JsonRpcRequest Base Class toArray() method, recursive serialization
Partial updates (JSON Merge Patch) PartialRequestInterface, wasProvided(), RFC 7396 semantics
File uploads multipart/form-data, acceptsMultipart, limits, and the CSRF trade-off
Troubleshooting / FAQ Common problems and their solutions
Upgrade Guide 4.x → 5.0 Every BC-breaking change in 5.0, what breaks and what to do about it
CHANGELOG Version history

Logging

Optional Request/Response logging subsystem with sensitive-data masking through a PSR-3 logger. Disabled by default. Details — docs/logging.md.

Partial updates (JSON Merge Patch)

The bundle supports PATCH semantics per RFC 7396 for Update methods where the client sends only changed fields.

Problem: the standard if ($request->getX() !== null) { $entity->setX($request->getX()); } pattern cannot distinguish "field not in payload" from "field sent as null" — both give null on the DTO. This means a field cannot be cleared via PATCH.

Solution: the Request DTO implements PartialRequestInterface, and the framework tracks which fields actually arrived in the payload. The service layer uses wasProvided('x') instead of !== null:

use OV\JsonRPCAPIBundle\Core\Request\PartialUpdateRequest;

class UpdateUserRequest extends PartialUpdateRequest
{
    private ?int $id = null;
    private ?string $email = null;
    private ?string $bio = null;
    // getters/setters...
}
public function call(UpdateUserRequest $request): Response
{
    $user = $this->userRepository->find($request->getId());

    if ($request->wasProvided('email')) {
        $user->setEmail($request->getEmail()); // null = clear
    }
    if ($request->wasProvided('bio')) {
        $user->setBio($request->getBio());
    }
    // ...
}

Payload semantics:

Payload wasProvided Service behavior
{"email": "new@x.com"} true set the new value
{"email": null} true clear the field (null)
{} (key absent) false leave the field untouched

Opt-in: only DTOs implementing PartialRequestInterface get tracking. Existing DTOs work without changes (full backward compatibility).

Details and edge cases — in docs/partial_updates.md.

API Versioning

API version is determined from the URL (/api/v1, /api/v2) or explicitly via the version parameter in the attribute:

#[JsonRPCAPI(methodName: 'getProduct', type: 'POST', version: 2)]

If version is not specified, it's extracted from the class namespace (e.g., App\RPC\V1 -> version 1).

Batch Requests

The bundle supports batch JSON-RPC requests per specification:

curl -X POST http://localhost/api/v1 \
  -H "Content-Type: application/json" \
  -d '[
    {"jsonrpc": "2.0", "method": "sum", "params": [1, 2, 4], "id": "1"},
    {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]},
    {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}
  ]'

Pre- and Post-processors

Processors allow executing logic before and after API method calls (logging, audit, notifications, etc.):

use OV\JsonRPCAPIBundle\Core\PreProcessorInterface;
use OV\JsonRPCAPIBundle\Core\PostProcessorInterface;

#[JsonRPCAPI(methodName: 'getProduct', type: 'POST')]
class GetProductMethod implements PreProcessorInterface, PostProcessorInterface
{
    public function getPreProcessors(): array
    {
        return [
            static::class => ['logRequest'],
        ];
    }

    public function getPostProcessors(): array
    {
        return [
            static::class => ['logResponse'],
        ];
    }

    public function logRequest(string $processorClass, ?object $request = null): void
    {
        // Called BEFORE call()
    }

    public function logResponse(string $processorClass, ?object $request = null, ?OvResponseInterface $response = null): void
    {
        // Called AFTER call()
    }

    public function call(Request $request): Response
    {
        // Main logic
    }
}

Details: docs/examples/pre-and-post-processors.md

Swagger / OpenAPI

Generating Documentation

bin/console ov:swagger:generate

Generates public/openapi/api_v1.yaml file for use with Swagger UI.

Documentation Annotations

Scalar properties:

use OV\JsonRPCAPIBundle\Core\Annotation\SwaggerProperty;

class Response
{
    #[SwaggerProperty(default: 'true', example: 'true')]
    private bool $success;

    #[SwaggerProperty(format: 'email', example: 'user@example.com')]
    private string $email;
}

Arrays:

use OV\JsonRPCAPIBundle\Core\Annotation\SwaggerArrayProperty;

class Response
{
    #[SwaggerArrayProperty(type: 'string')]
    private array $errors = [];

    #[SwaggerArrayProperty(type: Product::class, ofClass: true)]
    private array $products = [];
}

Tags for grouping:

#[JsonRPCAPI(methodName: 'getProduct', type: 'POST', tags: ['products'])]

Details:

Security

Role-Based Access

Restrict method access by roles via the roles attribute:

#[JsonRPCAPI(
    methodName: 'deleteUser',
    type: 'POST',
    roles: ['ROLE_ADMIN', 'ROLE_SUPER_ADMIN']
)]
class DeleteUserMethod implements ApiMethodInterface
{
    public function call(Request $request): Response { /* ... */ }
}

If the user lacks the required role, the bundle returns a normal JSON-RPC error object with code -32000 and HTTP status 200 — not HTTP 403: {"jsonrpc": "2.0", "error": {"code": -32000, "message": "Access denied."}, "id": ...}.

Authentication

The bundle is compatible with any Symfony authentication method:

Testing

Run the test suite:

./vendor/bin/phpunit tests/

Coverage reports require a coverage driver (xdebug or pcov). The phpunit.xml.dist config already declares the <source> block for PHPUnit 10+ coverage output.

Mutation testing (also requires a coverage driver):

composer infection

The bundled test suite covers:

  • Unit tests — every Core component, services, request/response models, DI, Swagger models.
  • Integration tests — full request lifecycle through the controller.
  • Command tests — Swagger YAML generation.
  • Security regression tests (tests/Security/) — DoS limits (payload, batch, DTO depth, array size), error sanitization, CORS origin matching, setter visibility, command path containment.

See docs/testing.md for guidance on writing tests for your own RPC methods.

Configuration

ov_json_rpc_api Parameters

Parameter Default Description
access_control_allow_origin_list [] Allowed CORS origins. Use ['*'] for wildcard, or list exact origins for matching against the request Origin header. Origins outside the list receive no CORS header at all — the legacy comma-joined fallback no longer exists.
cors_allowed_headers ['Content-Type'] Headers allowed in the CORS preflight response (Access-Control-Allow-Headers). The bundle handles OPTIONS preflight requests itself.
strict_notifications true Strict JSON-RPC 2.0 Notification compliance. When true — server does not respond to notifications (per spec). When false — server returns a response even for notifications if the result is non-empty (legacy 3.x behaviour).
allow_extra_fields false When false, request params containing fields not declared on the Request DTO are rejected with INVALID_PARAMS. Can be overridden per-method via the #[JsonRPCAPI(allowExtraFields: true)] attribute.
expose_internal_errors false When false (production-safe), uncaught non-JRPCException throwables are replaced with a generic Internal error. payload and the original is sent to the logger. Set to true only in dev to expose raw exception messages.
max_payload_bytes 1048576 Maximum bytes accepted for the raw request body. Larger requests are rejected with INVALID_REQUEST.
max_json_depth 64 Maximum allowed JSON nesting depth when decoding the payload. Deeper inputs are rejected with PARSE_ERROR.
max_batch_size 50 Maximum number of requests allowed in a single JSON-RPC batch. Larger batches return a single INVALID_REQUEST error.
max_dto_depth 10 Maximum recursion depth when hydrating nested Request DTO objects. Prevents stack/memory exhaustion via deeply nested payloads.
max_array_param_size 1000 Maximum element count for array parameters bound through addX() adders.
logging.enabled false Request and response logging. Everything else under logging.* applies only when this is true.
logging.request_level 'info' PSR-3 level for the incoming request entry.
logging.response_level 'info' PSR-3 level for a successful response entry.
logging.error_response_level 'warning' PSR-3 level for an error response entry.
logging.max_body_length 8192 Truncation of request and response bodies in the log, in characters. 0 disables truncation. The 4.x default was 0.
logging.skip_plain_responses true Do not log the body of PlainResponseInterface responses (files, streams).
logging.logger_service null Service id of the PSR-3 logger JsonRpcCallLogger writes to.
logging.call_logger_service null Service id replacing the JsonRpcCallLoggerInterface implementation outright.
logging.masking.placeholder '***' What replaces the value of a masked field.
logging.masking.key_patterns 29 patterns Regular expressions for field and header names whose values are masked (password, token, secret, authorization, jwt and others). The 4.x default was [], meaning no masking at all. Supplying your own list replaces the defaults rather than adding to them. An invalid expression fails container compilation.
swagger Swagger configuration per API version.
swagger.*.api_version '1' API version number.
swagger.*.base_path Production server URL.
swagger.*.test_path null Test server URL.
swagger.*.base_path_variables [] Variables for base_path substitution.
swagger.*.test_path_variables [] Variables for test_path substitution.
swagger.*.auth_token_name Authorization token header name.
swagger.*.auth_token_test_value Test token value. Currently unused: only auth_token_name reaches the OpenAPI security scheme; the value is substituted nowhere. Kept so existing configs keep compiling.
swagger.*.info API information (title, description, contact, license).

Security hardening: see docs/security_hardening.md for recommended values, rationale, and tuning tips for high-volume APIs.

#[JsonRPCAPI] Attribute Parameters

Parameter Type Required Default Description
methodName string yes JSON-RPC method name
type string yes HTTP method (POST, GET, PUT, PATCH, DELETE)
version ?int no null API version (if null — determined from namespace)
summary string no '' Short description for Swagger
description string no '' Detailed description for Swagger
tags ?array no null Tags for Swagger grouping
roles array no [] Required roles for access
ignoreInSwagger bool no false Exclude method from Swagger documentation
group ?string no null Swagger path group (e.g., 'products' -> /products/get_product)
allowExtraFields bool no false Accept fields in params that the Request DTO does not declare. Overrides the global allow_extra_fields for this method and applies at every nesting level.

JSON-RPC Error Codes

Code Constant Description
-32700 PARSE_ERROR JSON parsing error
-32600 INVALID_REQUEST Invalid JSON-RPC request
-32601 METHOD_NOT_FOUND Method not found
-32602 INVALID_PARAMS Invalid parameters
-32603 INTERNAL_ERROR Internal error
-32000 SERVER_ERROR Server error

Versioning and Backward Compatibility

The bundle follows Semantic Versioning:

  • Major releases (4.0, 5.0, ...) may change behaviour and the public API. Every breaking change is listed in CHANGELOG.md and comes with an upgrade guide (docs/upgrade-5.0.md, docs/upgrade-4.0.md).
  • Minor releases add functionality without breaking existing integrations; behaviour under existing configuration defaults does not change.
  • Patch releases contain bug and security fixes only.

The backward-compatibility promise covers everything an integrator touches:

  • the configuration schema under ov_json_rpc_api.* and its defaults;
  • the #[JsonRPCAPI] attribute and its parameters;
  • the wire format — JSON-RPC 2.0 request, response, error and batch shapes;
  • the interfaces the integrator implements or consumes: ApiMethodInterface, PreProcessorInterface, PostProcessorInterface, PlainResponseInterface, JsonRpcCallLoggerInterface, JsonRpcLogFormatterInterface, SensitiveDataMaskerInterface, ContextIdGeneratorInterface;
  • the ov:swagger:generate command and the structure of the OpenAPI output.

Internal service classes not listed above are implementation detail and may change in a minor release. Functionality scheduled for removal in the next major is, when feasible, deprecated in a preceding minor release and noted in the CHANGELOG.

The list of versions currently receiving fixes is in SECURITY.md.

Contributing

See CONTRIBUTING.md for the development setup, test requirements, and PR expectations. To report a vulnerability, see SECURITY.md — please do not open a public issue.

License

MIT

Author

Leonid Groshev — OtezVikentiy@gmail.comotezvikentiy.tech