1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Exception;
6:
7: use LaravelUi5\OData\Http\ODataResponse;
8: use Illuminate\Contracts\Support\Responsable;
9: use Illuminate\Support\Facades\App;
10: use RuntimeException;
11: use Throwable;
12:
13: /**
14: * @link https://docs.oasis-open.org/odata/odata/v4.01/os/part1-protocol/odata-v4.01-os-part1-protocol.html#sec_ErrorResponseBody
15: */
16: abstract class ProtocolException extends RuntimeException implements Responsable
17: {
18: protected $httpCode = ODataResponse::HTTP_INTERNAL_SERVER_ERROR;
19: protected $odataCode;
20: protected $message;
21: protected $target = null;
22: protected $details = [];
23: protected $innerError = [];
24: protected $headers = [];
25: protected $suppressContent = false;
26: protected $originalException = null;
27:
28: public function __construct(?string $code = null, ?string $message = null, ?Throwable $originalException = null)
29: {
30: if ($code) {
31: $this->odataCode = $code;
32: }
33:
34: if ($message) {
35: $this->message = $message;
36: }
37:
38: if ($originalException) {
39: $this->originalException = $originalException;
40: }
41:
42: parent::__construct($this->message);
43: }
44:
45: /**
46: * Control whether Laravel's exception handler logs this exception.
47: *
48: * A `4xx` OData error is an **expected client outcome** (a malformed query, an
49: * unauthorized read, an unknown set) — not a server fault — so it must not spam the
50: * error log with a stack trace. A `5xx` is a genuine engine failure and is reported.
51: *
52: * The return value follows Laravel's `report()` contract (Foundation `Handler`):
53: * a **non-false** return means "handled — skip default logging"; a **false** return
54: * lets the handler perform its normal error logging. Hence: `4xx → true` (suppressed),
55: * `5xx → false` (logged). The HTTP response is unchanged either way — this governs
56: * server-side logging only.
57: */
58: public function report(): bool
59: {
60: if ($this->httpCode < 500) {
61: return true; // expected client outcome → suppress default logging
62: }
63:
64: return false; // genuine server error → let Laravel log it
65: }
66:
67: /**
68: * Set the OData error code
69: * @param string $code Code
70: * @return $this
71: */
72: public function code(string $code): self
73: {
74: $this->odataCode = $code;
75: return $this;
76: }
77:
78: /**
79: * Set the OData error message
80: * @param string $message Message
81: * @return $this
82: */
83: public function message(string $message): self
84: {
85: $this->message = $message;
86: return $this;
87: }
88:
89: /**
90: * Set the OData error target
91: * @param string $target Target
92: * @return $this
93: */
94: public function target(string $target): self
95: {
96: $this->target = $target;
97: return $this;
98: }
99:
100: /**
101: * Set the OData error details
102: * @param string $code Details
103: * @return $this
104: */
105: public function addDetail(string $code, string $message, ?string $target = null): self
106: {
107: $detail = [
108: 'code' => $code,
109: 'message' => $message,
110: ];
111:
112: if ($target) {
113: $detail['target'] = $target;
114: }
115:
116: $this->details[] = $detail;
117:
118: return $this;
119: }
120:
121: /**
122: * Set the OData inner error
123: * @param string $key Key
124: * @param string $value Value
125: * @return $this
126: */
127: public function addInnerError(string $key, string $value): self
128: {
129: $this->innerError[$key] = $value;
130:
131: return $this;
132: }
133:
134: /**
135: * Set a header on the outgoing response
136: * @param string $key Key
137: * @param string $value Value
138: * @return $this
139: */
140: public function header(string $key, string $value): self
141: {
142: $this->headers[$key] = $value;
143: return $this;
144: }
145:
146: /**
147: * Serialize this error
148: * @return array
149: */
150: public function serialize()
151: {
152: return array_filter([
153: 'httpCode' => $this->httpCode,
154: 'odataCode' => $this->odataCode,
155: 'message' => $this->message,
156: 'target' => $this->target,
157: 'details' => $this->details,
158: 'innererror' => $this->innerError,
159: 'headers' => $this->headers,
160: ]);
161: }
162:
163: /**
164: * Convert this exception to a Symfony error
165: * @return array
166: */
167: public function toError()
168: {
169: return [
170: 'code' => $this->odataCode,
171: 'message' => $this->message,
172: 'target' => $this->target,
173: 'details' => $this->details,
174: 'innererror' => $this->innerError ?: (object) [],
175: ];
176: }
177:
178: /**
179: * Get the original exception that caused this exception
180: *
181: * @return Throwable|null
182: */
183: public function getOriginalException(): ?Throwable
184: {
185: return $this->originalException;
186: }
187:
188: /**
189: * Convert this exception to a Symfony response
190: * @param null $request Request
191: * @return ODataResponse Response
192: */
193: public function toResponse($request = null): ODataResponse
194: {
195: $response = App::make(ODataResponse::class);
196:
197: $response->setCallback(function () {
198: if ($this->suppressContent) {
199: return;
200: }
201:
202: echo json_encode(['error' => $this->toError()], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
203: });
204:
205: $response->setProtocolVersion('1.1');
206: $response->setStatusCode($this->httpCode);
207: $response->headers->replace($this->headers);
208: $response->headers->set('content-type', 'application/json');
209:
210: return $response;
211: }
212:
213: public function getInnerException(): ?Throwable
214: {
215: return $this->originalException;
216: }
217: }
218: