1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Http;
6:
7: use Illuminate\Http\Request;
8: use LaravelUi5\OData\Exception\ForbiddenException;
9: use LaravelUi5\OData\Protocol\Execution\Engine;
10: use LaravelUi5\OData\Protocol\Planning\EntityQueryPlan;
11: use LaravelUi5\OData\Protocol\Planning\EntitySetQueryPlan;
12: use LaravelUi5\OData\Protocol\Planning\ExpandPruner;
13: use LaravelUi5\OData\Protocol\Planning\QueryPlan;
14: use LaravelUi5\OData\Service\Contracts\ReadAuthorizerInterface;
15: use LaravelUi5\OData\Service\Contracts\RuntimeSchemaInterface;
16: use LaravelUi5\OData\Service\ReadContext;
17: use LaravelUi5\OData\Service\ReadMessage;
18:
19: /**
20: * The read-authorization gate: authorize a plan, then execute it.
21: *
22: * Shared by the direct read path ({@see \LaravelUi5\OData\Http\Controller\OData::forService})
23: * and each `$batch` inner request, so both enforce identically:
24: *
25: * - a hard denial (a primary / root target) → {@see ForbiddenException} (403);
26: * - a gated `$expand` → pruned from the plan + a `sap-messages` warning on the 200;
27: * - otherwise → served as-is.
28: *
29: * For a `$batch` inner request the **outer** `$batch` Request is passed: the plan is
30: * inner-specific and the actor is request-scoped, so per-set gating is correct — an enforcer
31: * decides off the plan and the actor, never `Request::path()`.
32: */
33: final readonly class ReadGate
34: {
35: public function __construct(private ReadAuthorizerInterface $authorizer)
36: {
37: }
38:
39: public function execute(
40: QueryPlan $plan,
41: Request $request,
42: RuntimeSchemaInterface $schema,
43: string $endpoint,
44: ): ODataResponse {
45: $read = new ReadContext();
46: $this->authorizer->authorize($plan, $request, $read);
47:
48: if ($read->hasHardDenial()) {
49: throw ForbiddenException::fromContext($read);
50: }
51:
52: if ($read->dropped() !== []) {
53: $plan = $this->pruneDroppedExpands($plan, $read->dropped());
54: }
55:
56: $response = (new Engine($schema, $endpoint))->execute($plan);
57:
58: if ($read->dropMessages() !== []) {
59: $response->headers->set('sap-messages', $this->encodeSapMessages($read->dropMessages()));
60: }
61:
62: return $response;
63: }
64:
65: /**
66: * Rebuild the plan without the gated `$expand` targets. Read authorization is per entity
67: * set, so a dropped set name removes every expand pointing at it, at any depth. Plan types
68: * that carry no expands are returned unchanged.
69: *
70: * @param list<string> $droppedSetNames
71: */
72: private function pruneDroppedExpands(QueryPlan $plan, array $droppedSetNames): QueryPlan
73: {
74: if ($plan instanceof EntitySetQueryPlan || $plan instanceof EntityQueryPlan) {
75: return $plan->withExpand(ExpandPruner::prune($plan->expand, $droppedSetNames));
76: }
77:
78: return $plan;
79: }
80:
81: /**
82: * Serialize the drop messages as the standard `sap-messages` header value (a JSON array of
83: * unbound messages) — the carrier the UI5 v4 model ingests natively.
84: *
85: * @param list<ReadMessage> $messages
86: */
87: private function encodeSapMessages(array $messages): string
88: {
89: return json_encode(
90: array_map(static fn (ReadMessage $message) => $message->toArray(), $messages),
91: JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
92: );
93: }
94: }
95: