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