Skip to content

feat: Runtime\Dispatcher — call an action without HTTP - #19

Merged
dskripchenko merged 1 commit into
masterfrom
feature/runtime-dispatcher
Oct 26, 2026
Merged

dskripchenko merged 1 commit into
masterfrom
feature/runtime-dispatcher

Conversation

@dskripchenko

Copy link
Copy Markdown
Owner

First step of stage 2 (transports): a way to call an action that isn't an HTTP request of its own — what MCP / JSON-RPC will build on.

What

$result = app(Dispatcher::class)->dispatch(new ApiInvocation('v1', 'user', 'create', ['name' => 'Ann']));
$result->success; $result->payload; $result->status; $result->errorKey(); $result->message();
  • ApiInvocation — version, controller, action, params, headers, optional HTTP method (default: the action's first).
  • Dispatcher — builds a BaseApiRequest for the action's URI and sends it through Router::dispatch(): the route middleware cascade, FormRequest/validation, auth(), ApiRequest, route-cache — all as over HTTP. It does not re-run the global kernel middleware: the request it runs inside already went through it. Any throwable → ApiErrorHandler, like BaseApi::callAction.
  • ApiResult::fromResponse() — reads the {success, payload} envelope from the body, the way an HTTP client does (so a middleware refusal or the error handler's answer reads the same as a controller's); falls back to the decoded body / raw text / empty.
  • Runs as the same caller: the outer request's HTTP_* headers (not content-type/length) and cookies are passed on; the invocation's headers on top.
  • Puts everything back afterwards: container request, Route::class, the request and api_request facades, BaseApiRequest::$_instance (new BaseApiRequest::swapInstance()), and the router's protected $current / $currentRequest.
  • Input encoding as a client would send it: GET → a real query string (values arrive as strings, as over HTTP); otherwise a JSON body (types kept, as a JSON client).

Deviation from the plan

The plan had ApiResult extends JsonResponse returned by ApiResponseHelper::say(). Not needed: reading the body the way a client does works for every response (controller, middleware refusal, error handler) without touching the response types — so nothing in the HTTP path changes. ApiResult is a plain value object. Likewise no separate "neutral error" layer: the error handler's answers already carry errorKey/message in the envelope.

Not in this PR

terminate() of terminable middleware isn't called for a dispatched call (no kernel). A "pure" call without a synthetic request stays a 7.0 topic.

Tests

608 passing; golden-master unchanged. New tests/Feature/Runtime/DispatcherTest.php (7): a call and its answer; middleware cascade (refused without the header, passes with it); GET query vs JSON body input; default vs explicit HTTP method (wrong method → 404 http_error); unknown action and inactive version; a call inside an HTTP request — authenticated by the outer header, and afterwards request(), Route::current(), request()->input() and ApiRequest::input() are the outer request's again; ApiResult on non-envelope / non-JSON / empty answers.

Docs: README (en/ru/zh/de) "Calling an action without HTTP", CHANGELOG, CLAUDE.md.

🤖 Generated with Claude Code

ApiInvocation goes through the router the way a request to the action's
URL would (middleware cascade, validation, auth, ApiRequest) and comes
back as an ApiResult read from the {success, payload} envelope. It runs
inside the current request as the same caller and restores that request,
the facades, ApiRequest and the router's current route afterwards. GET
input travels as a query string, the rest as a JSON body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dskripchenko
dskripchenko merged commit 42a74ed into master Oct 26, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant