1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Service;
6:
7: /**
8: * The read-side collector — the read analog of a write-side transaction seal.
9: *
10: * A single read can touch many targets (a root set plus its `$expand` navigation
11: * targets). A {@see \LaravelUi5\OData\Service\Contracts\ReadAuthorizerInterface} records
12: * a verdict per target into this collector; the controller / engine then reads it to shape
13: * the response:
14: *
15: * - a **hard denial** (a primary / root target) → the controller answers a 403
16: * {@see \LaravelUi5\OData\Exception\ForbiddenException};
17: * - a **drop** (an `$expand` target) → the engine prunes it from serialization and emits
18: * a `sap-messages` warning (the honest-partial model — added in the next slice);
19: * - no verdict recorded → the read proceeds.
20: *
21: * The default {@see AllowAllReadAuthorizer} records nothing, so an unconfigured OData
22: * proceeds exactly as before.
23: */
24: final class ReadContext
25: {
26: /** @var list<array{target: string, message: ReadMessage}> */
27: private array $hardDenials = [];
28:
29: /** @var list<array{target: string, message: ReadMessage}> */
30: private array $drops = [];
31:
32: /**
33: * Record that a target may be read. A no-op for response shaping; present for
34: * symmetry so an enforcer can be explicit.
35: */
36: public function allow(string $target): void
37: {
38: }
39:
40: /** Record a hard denial (a primary / root target) → the controller answers 403. */
41: public function denyHard(string $target, ReadMessage $message): void
42: {
43: $this->hardDenials[] = ['target' => $target, 'message' => $message];
44: }
45:
46: /**
47: * Record a droppable (`$expand`) denial → pruned from serialization + a `sap-messages`
48: * warning. Consumed by the honest-partial model (next slice); recorded here so the
49: * collector's contract is stable from the start.
50: */
51: public function denyDrop(string $target, ReadMessage $message): void
52: {
53: $this->drops[] = ['target' => $target, 'message' => $message];
54: }
55:
56: public function hasHardDenial(): bool
57: {
58: return $this->hardDenials !== [];
59: }
60:
61: /** @return list<array{target: string, message: ReadMessage}> */
62: public function hardDenials(): array
63: {
64: return $this->hardDenials;
65: }
66:
67: /** The first hard denial's message — the headline for the 403 error envelope. */
68: public function primaryDenial(): ?ReadMessage
69: {
70: return $this->hardDenials[0]['message'] ?? null;
71: }
72:
73: /** @return list<string> the dropped `$expand` target paths (next slice). */
74: public function dropped(): array
75: {
76: return array_column($this->drops, 'target');
77: }
78:
79: /** @return list<ReadMessage> the drop messages (for the `sap-messages` header, next slice). */
80: public function dropMessages(): array
81: {
82: return array_column($this->drops, 'message');
83: }
84: }
85: