1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http;
4:
5: use Salient\Contract\Core\Arrayable;
6: use Salient\Contract\Core\DateFormatterInterface;
7: use Salient\Contract\Core\Jsonable;
8: use Salient\Contract\Http\FormDataFlag;
9: use Salient\Core\Date\DateFormatter;
10: use Salient\Utility\Arr;
11: use Salient\Utility\Get;
12: use Salient\Utility\Json;
13: use Salient\Utility\Test;
14: use DateTimeInterface;
15: use InvalidArgumentException;
16: use JsonSerializable;
17: use Traversable;
18:
19: /**
20: * @api
21: */
22: final class FormData
23: {
24: /** @var mixed[]|object */
25: private $Data;
26:
27: /**
28: * @param mixed[]|object $data
29: */
30: public function __construct($data)
31: {
32: $this->Data = $data;
33: }
34:
35: /**
36: * Get form data as a list of key-value pairs
37: *
38: * List keys are not preserved by default. Use `$flags` to modify this
39: * behaviour.
40: *
41: * If no `$dateFormatter` is given, a {@see DateFormatter} is created to
42: * convert {@see DateTimeInterface} instances to ISO-8601 strings.
43: *
44: * `$callback` is applied to objects other than {@see DateTimeInterface}
45: * instances found in `$data`. It may return `null` to skip the value, or
46: * `false` to process the value as if no callback had been given. If a
47: * {@see DateTimeInterface} is returned, it is converted to `string` as per
48: * the `$dateFormatter` note above.
49: *
50: * If no `$callback` is given, objects are resolved as follows:
51: *
52: * - {@see DateTimeInterface}: converted to `string` (see `$dateFormatter`
53: * note above)
54: * - {@see Arrayable}: replaced with {@see Arrayable::toArray()}
55: * - {@see JsonSerializable}: replaced with
56: * {@see JsonSerializable::jsonSerialize()} if it returns an `array`
57: * - {@see Jsonable}: replaced with {@see Jsonable::toJson()} after decoding
58: * if {@see json_decode()} returns an `array`
59: * - `object` with at least one public property: replaced with an array that
60: * maps public property names to values
61: * - {@see Stringable}: cast to `string`
62: *
63: * @template T of object|mixed[]|string|null
64: *
65: * @param int-mask-of<FormDataFlag::*> $flags
66: * @param (callable(object): (T|false))|null $callback
67: * @return list<array{string,(T&object)|string}>
68: */
69: public function getValues(
70: int $flags = FormDataFlag::PRESERVE_NUMERIC_KEYS | FormDataFlag::PRESERVE_STRING_KEYS,
71: ?DateFormatterInterface $dateFormatter = null,
72: ?callable $callback = null
73: ): array {
74: $dateFormatter ??= new DateFormatter();
75: /** @var list<array{string,(T&object)|string}> */
76: return $this->doGetData($this->Data, $flags, $dateFormatter, $callback);
77: }
78:
79: /**
80: * Get form data as a URL-encoded query string
81: *
82: * Equivalent to calling {@see FormData::getValues()} and converting the
83: * result to a query string.
84: *
85: * @template T of object|mixed[]|string|null
86: *
87: * @param int-mask-of<FormDataFlag::*> $flags
88: * @param (callable(object): (T|false))|null $callback
89: */
90: public function getQuery(
91: int $flags = FormDataFlag::PRESERVE_NUMERIC_KEYS | FormDataFlag::PRESERVE_STRING_KEYS,
92: ?DateFormatterInterface $dateFormatter = null,
93: ?callable $callback = null
94: ): string {
95: $dateFormatter ??= new DateFormatter();
96: $data = $this->doGetData($this->Data, $flags, $dateFormatter, $callback);
97: foreach ($data as [$key, $value]) {
98: if (!is_string($value)) {
99: throw new InvalidArgumentException(sprintf(
100: "Invalid value at '%s': %s",
101: $key,
102: Get::type($value),
103: ));
104: }
105: $query[] = rawurlencode($key) . '=' . rawurlencode($value);
106: }
107: return implode('&', $query ?? []);
108: }
109:
110: /**
111: * Get form data as nested arrays of scalar values
112: *
113: * Similar to {@see FormData::getValues()}, but scalar types are preserved
114: * and data structures are not flattened.
115: *
116: * @template T of object|mixed[]|string|null
117: *
118: * @param int-mask-of<FormDataFlag::*> $flags
119: * @param (callable(object): (T|false))|null $callback
120: * @return mixed[]
121: */
122: public function getData(
123: int $flags = FormDataFlag::PRESERVE_NUMERIC_KEYS | FormDataFlag::PRESERVE_STRING_KEYS,
124: ?DateFormatterInterface $dateFormatter = null,
125: ?callable $callback = null
126: ): array {
127: $dateFormatter ??= new DateFormatter();
128: return $this->doGetData($this->Data, $flags, $dateFormatter, $callback, false);
129: }
130:
131: /**
132: * @template T of object|mixed[]|string|null
133: *
134: * @param mixed[]|object|int|float|string|bool|null $data
135: * @param int-mask-of<FormDataFlag::*> $flags
136: * @param (callable(object): (T|false))|null $cb
137: * @param mixed[]|null $query
138: * @phpstan-param ($flatten is true ? list<array{string,(T&object)|string}> : ($name is null ? mixed[] : mixed[]|null)) $query
139: * @param-out ($flatten is true ? list<array{string,(T&object)|string}> : ($name is null ? mixed[] : mixed[]|(T&object)|int|float|string|bool|null)) $query
140: * @return ($flatten is true ? list<array{string,(T&object)|string}> : ($name is null ? mixed[] : mixed[]|(T&object)|int|float|string|bool|null))
141: */
142: private function doGetData(
143: $data,
144: int $flags,
145: DateFormatterInterface $df,
146: ?callable $cb,
147: bool $flatten = true,
148: bool $fromCallback = false,
149: &$query = [],
150: ?string $name = null
151: ) {
152: if ($name === null) {
153: $data = $flatten
154: ? $this->flattenValue($data, $df, $cb)
155: : $this->resolveValue($data, $df, $cb, $fromCallback);
156: }
157:
158: /** @var mixed[]|(T&object)|int|float|string|bool|null $data */
159: if (
160: ($flatten && ($data === null || $data === []))
161: || ($fromCallback && $data === null)
162: ) {
163: return $query;
164: }
165:
166: if (is_array($data)) {
167: $hasArray = false;
168: if ($flatten) {
169: foreach ($data as $key => &$value) {
170: $value = $this->flattenValue($value, $df, $cb);
171: if (!$hasArray) {
172: $hasArray = is_array($value);
173: }
174: }
175: } else {
176: /** @var array<array-key,bool> */
177: $fromCallback = [];
178: foreach ($data as $key => &$value) {
179: $value = $this->resolveValue($value, $df, $cb, $fromCallback[$key]);
180: }
181: }
182: unset($value);
183:
184: $preserveKeys = $name === null || $hasArray || (
185: Arr::isList($data)
186: ? $flags & FormDataFlag::PRESERVE_LIST_KEYS
187: : (Arr::hasNumericKeys($data)
188: ? $flags & FormDataFlag::PRESERVE_NUMERIC_KEYS
189: : $flags & FormDataFlag::PRESERVE_STRING_KEYS)
190: );
191:
192: $format = $preserveKeys
193: ? ($flatten && $name !== null ? '[%s]' : '%s')
194: : ($flatten ? '[]' : '');
195:
196: if ($flatten) {
197: /** @var object|mixed[]|string|null $value */
198: foreach ($data as $key => $value) {
199: $_key = sprintf($format, $key);
200: $this->doGetData($value, $flags, $df, $cb, true, false, $query, $name . $_key);
201: }
202: } else {
203: /** @var object|mixed[]|string|null $value */
204: foreach ($data as $key => $value) {
205: $_key = sprintf($format, $key);
206: if ($_key === '') {
207: $query[] = null;
208: $_key = array_key_last($query);
209: }
210: // @phpstan-ignore offsetAccess.nonOffsetAccessible
211: $this->doGetData($value, $flags, $df, $cb, false, $fromCallback[$key], $query[$_key], '');
212: }
213: }
214:
215: return $query;
216: }
217:
218: if ($flatten) {
219: $query[] = [$name, $data];
220: return $query;
221: }
222:
223: if ($name === null) {
224: throw new InvalidArgumentException('Argument #1 ($data) must resolve to an array');
225: }
226:
227: $query = $data;
228: return $query;
229: }
230:
231: /**
232: * @template T of object|mixed[]|string|null
233: *
234: * @param mixed $value
235: * @param (callable(object): (T|false))|null $cb
236: * @return mixed[]|(T&object)|string|null
237: */
238: private function flattenValue(
239: $value,
240: DateFormatterInterface $df,
241: ?callable $cb
242: ) {
243: if (is_bool($value)) {
244: return (string) (int) $value;
245: }
246:
247: if ($value === null || is_scalar($value)) {
248: return (string) $value;
249: }
250:
251: if (is_array($value)) {
252: return $value;
253: }
254:
255: if (is_object($value)) {
256: $value = $this->convertObject($value, $df, $cb, $fromCallback);
257: if (!$fromCallback && ($value === null || is_scalar($value))) {
258: // @phpstan-ignore cast.string
259: return (string) $value;
260: }
261: /** @var mixed[]|(T&object)|string|null */
262: return $value;
263: }
264:
265: throw new InvalidArgumentException(sprintf(
266: 'Invalid value: %s',
267: Get::type($value),
268: ));
269: }
270:
271: /**
272: * @template T of object|mixed[]|string|null
273: *
274: * @param mixed $value
275: * @param (callable(object): (T|false))|null $cb
276: * @param-out bool $fromCallback
277: * @return T|mixed[]|int|float|string|bool|null
278: */
279: private function resolveValue(
280: $value,
281: DateFormatterInterface $df,
282: ?callable $cb,
283: ?bool &$fromCallback
284: ) {
285: $fromCallback = false;
286:
287: if ($value === null || is_scalar($value) || is_array($value)) {
288: return $value;
289: }
290:
291: if (is_object($value)) {
292: return $this->convertObject($value, $df, $cb, $fromCallback);
293: }
294:
295: throw new InvalidArgumentException(sprintf(
296: 'Invalid value: %s',
297: Get::type($value),
298: ));
299: }
300:
301: /**
302: * @template T of object|mixed[]|string|null
303: *
304: * @param (callable(object): (T|false))|null $cb
305: * @param-out bool $fromCallback
306: * @return T|mixed[]|int|float|string|bool|null
307: */
308: private function convertObject(
309: object $value,
310: DateFormatterInterface $df,
311: ?callable $cb,
312: ?bool &$fromCallback
313: ) {
314: $fromCallback = false;
315:
316: if ($value instanceof DateTimeInterface) {
317: return $df->format($value);
318: }
319:
320: if ($cb !== null) {
321: $result = $cb($value);
322: if ($result instanceof DateTimeInterface) {
323: return $df->format($result);
324: }
325: if ($result !== false) {
326: $fromCallback = true;
327: return $result;
328: }
329: }
330:
331: if ($value instanceof Arrayable) {
332: return $value->toArray();
333: }
334:
335: if ($value instanceof JsonSerializable) {
336: return Json::objectAsArray(Json::encode($value));
337: }
338:
339: if ($value instanceof Jsonable) {
340: return Json::objectAsArray($value->toJson());
341: }
342:
343: if ($value instanceof Traversable) {
344: return iterator_to_array($value);
345: }
346:
347: // Get public property values
348: $result = [];
349: // @phpstan-ignore foreach.nonIterable
350: foreach ($value as $key => $val) {
351: $result[$key] = $val;
352: }
353: if ($result !== []) {
354: return $result;
355: }
356:
357: if (Test::isStringable($value)) {
358: return (string) $value;
359: }
360:
361: return [];
362: }
363: }
364: