Skip to content

Commit 0a61b55

Browse files
committed
Add HTTP stream factory methods
- Add `Get::formData()` by reworking `Get::query()` and related methods - Add `HttpMultipartStreamPartInterface::withName()` - In `HttpMultipartStreamPart`, allow field name to be `null` initially - Add `HttpMultipartStreamPart::fromFile()` - Add `HttpStream::fromData()` - Remove inconsistently applied `$preserveKeys` parameter from `Get::array()` - Add tests
1 parent 6f65ef0 commit 0a61b55

File tree

14 files changed

+655
-125
lines changed

14 files changed

+655
-125
lines changed

src/Toolkit/Contract/Http/HttpMultipartStreamPartInterface.php

+5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
*/
1111
interface HttpMultipartStreamPartInterface extends Immutable
1212
{
13+
/**
14+
* Get an instance with the given field name
15+
*/
16+
public function withName(string $name): HttpMultipartStreamPartInterface;
17+
1318
/**
1419
* Get the field name of the part
1520
*/

src/Toolkit/Core/AbstractStore.php

+2
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,9 @@ final protected function requireUpsert()
305305
return $this;
306306
}
307307

308+
// @codeCoverageIgnoreStart
308309
throw new InvalidRuntimeConfigurationException('SQLite 3.24 or above required');
310+
// @codeCoverageIgnoreEnd
309311
}
310312

311313
/**

src/Toolkit/Core/Utility/File.php

+2
Original file line numberDiff line numberDiff line change
@@ -1108,9 +1108,11 @@ public static function writeCsv(
11081108

11091109
if ($utf16le) {
11101110
if (!extension_loaded('iconv')) {
1111+
// @codeCoverageIgnoreStart
11111112
throw new InvalidRuntimeConfigurationException(
11121113
"'iconv' extension required for UTF-16LE encoding"
11131114
);
1115+
// @codeCoverageIgnoreEnd
11141116
}
11151117
$filter = @stream_filter_append($handle, 'convert.iconv.UTF-8.UTF-16LE', \STREAM_FILTER_WRITE);
11161118
self::maybeThrow($filter, 'Error applying UTF-16LE filter to stream: %s', $uri, $handle);

src/Toolkit/Core/Utility/Get.php

+148-85
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
use ReflectionClass;
2525
use ReflectionObject;
2626
use Stringable;
27-
use Traversable;
2827
use UnitEnum;
2928

3029
/**
@@ -194,7 +193,45 @@ public static function filter(array $values, bool $discardInvalid = true): array
194193
}
195194

196195
/**
197-
* Convert nested arrays to a query string
196+
* Convert nested arrays and objects to a query string
197+
*
198+
* @see Get::formData() for details.
199+
*
200+
* @template T of object|mixed[]|string|null
201+
*
202+
* @param mixed[] $data
203+
* @param int-mask-of<QueryFlag::*> $flags
204+
* @param (callable(object): (T|false))|null $callback
205+
*/
206+
public static function query(
207+
array $data,
208+
int $flags = QueryFlag::PRESERVE_NUMERIC_KEYS | QueryFlag::PRESERVE_STRING_KEYS,
209+
?DateFormatterInterface $dateFormatter = null,
210+
?callable $callback = null
211+
): string {
212+
$data = self::doFormData(
213+
$data,
214+
$flags,
215+
$dateFormatter ?? new DateFormatter(),
216+
$callback,
217+
);
218+
219+
foreach ($data as [$key, $value]) {
220+
if (!is_string($value)) {
221+
throw new InvalidArgumentException(sprintf(
222+
"Invalid value at '%s': %s",
223+
$key,
224+
self::type($value),
225+
));
226+
}
227+
$query[] = rawurlencode($key) . '=' . rawurlencode($value);
228+
}
229+
230+
return implode('&', $query ?? []);
231+
}
232+
233+
/**
234+
* Convert nested arrays and objects to HTML form data
198235
*
199236
* List keys are not preserved by default. Use `$flags` to modify this
200237
* behaviour.
@@ -203,107 +240,120 @@ public static function filter(array $values, bool $discardInvalid = true): array
203240
* convert {@see DateTimeInterface} instances to ISO-8601 strings.
204241
*
205242
* `$callback` is applied to objects other than {@see DateTimeInterface}
206-
* instances found in `$data`. If it returns `null`, the value is excluded
207-
* from the query string.
243+
* instances found in `$data`. It may return `null` to skip the value, or
244+
* `false` to process the value as if no callback had been given. If a
245+
* {@see DateTimeInterface} is returned, it is converted to `string` as per
246+
* the `$dateFormatter` note above.
208247
*
209-
* Objects are resolved as follows:
248+
* If no `$callback` is given, objects are resolved as follows:
210249
*
211250
* - {@see DateTimeInterface}: converted to `string` (see `$dateFormatter`
212251
* note above)
213252
* - {@see Arrayable}: replaced with {@see Arrayable::toArray()}
214-
* - {@see Jsonable}: replaced with {@see Jsonable::toJson()} and decoded
215-
* - {@see Stringable}: cast to `string` unless {@see JsonSerializable} is
216-
* also implemented
217-
* - Others: converted to JSON and decoded
253+
* - {@see JsonSerializable}: replaced with
254+
* {@see JsonSerializable::jsonSerialize()} if it returns an `array`
255+
* - {@see Jsonable}: replaced with {@see Jsonable::toJson()} after decoding
256+
* if {@see json_decode()} returns an `array`
257+
* - `object` with at least one public property: replaced with an array that
258+
* maps public property names to values
259+
* - {@see Stringable}: cast to `string`
218260
*
219-
* @param mixed[] $data
261+
* @template T of object|mixed[]|string|null
262+
*
263+
* @param mixed[]|object $data
220264
* @param int-mask-of<QueryFlag::*> $flags
221-
* @param (callable(object): (object|mixed[]|string|null))|null $callback
265+
* @param (callable(object): (T|false))|null $callback
266+
* @return list<array{string,string|(T&object)}>
222267
*/
223-
public static function query(
224-
array $data,
268+
public static function formData(
269+
$data,
225270
int $flags = QueryFlag::PRESERVE_NUMERIC_KEYS | QueryFlag::PRESERVE_STRING_KEYS,
226271
?DateFormatterInterface $dateFormatter = null,
227272
?callable $callback = null
228-
): string {
229-
return implode('&', self::doQuery(
273+
) {
274+
/** @var list<array{string,string|(T&object)}> */
275+
return self::doFormData(
230276
$data,
231277
$flags,
232-
$dateFormatter ?: new DateFormatter(),
278+
$dateFormatter ?? new DateFormatter(),
233279
$callback,
234-
));
280+
);
235281
}
236282

237283
/**
238-
* @param mixed[] $data
284+
* @template T of object|mixed[]|string|null
285+
*
286+
* @param object|mixed[]|string|null $data
239287
* @param int-mask-of<QueryFlag::*> $flags
240-
* @param (callable(object): (object|mixed[]|string|null))|null $cb
241-
* @param string[] $query
242-
* @return string[]
288+
* @param (callable(object): (T|false))|null $cb
289+
* @param list<array{string,string|(T&object)}> $query
290+
* @return list<array{string,string|(T&object)}>
243291
*/
244-
private static function doQuery(
245-
array $data,
292+
private static function doFormData(
293+
$data,
246294
int $flags,
247295
DateFormatterInterface $df,
248296
?callable $cb,
249297
array &$query = [],
250-
string $keyPrefix = '',
251-
string $keyFormat = '%s'
298+
string $name = ''
252299
): array {
253-
foreach ($data as $key => $value) {
254-
$key = $keyPrefix . sprintf($keyFormat, $key);
300+
if ($name === '') {
301+
$data = self::getFormDataValue($data, $df, $cb);
302+
}
255303

256-
if ($keyPrefix === '') {
257-
$value = self::getQueryValue($value, $df, $cb, $key);
258-
}
304+
/** @var (T&object)|mixed[]|string|null $data */
305+
if ($data === null || $data === []) {
306+
return $query;
307+
}
259308

260-
/** @var mixed[]|string|null $value */
261-
if ($value === null || $value === []) {
262-
continue;
263-
}
309+
if (is_array($data)) {
310+
$preserveKeys = $name === '' || (
311+
Arr::isList($data)
312+
? $flags & QueryFlag::PRESERVE_LIST_KEYS
313+
: (Arr::isIndexed($data)
314+
? $flags & QueryFlag::PRESERVE_NUMERIC_KEYS
315+
: $flags & QueryFlag::PRESERVE_STRING_KEYS)
316+
);
317+
$format = $preserveKeys ? ($name === '' ? '%s' : '[%s]') : '[]';
264318

265-
if (is_array($value)) {
266-
$preserveKeys =
267-
Arr::isList($value)
268-
? $flags & QueryFlag::PRESERVE_LIST_KEYS
269-
: (Arr::isIndexed($value)
270-
? $flags & QueryFlag::PRESERVE_NUMERIC_KEYS
271-
: $flags & QueryFlag::PRESERVE_STRING_KEYS);
272-
$format = $preserveKeys ? '[%s]' : '[]';
273-
274-
$hasArray = false;
275-
foreach ($value as $k => &$v) {
276-
$k = $key . sprintf($format, $k);
277-
$v = self::getQueryValue($v, $df, $cb, $k);
278-
if (!$preserveKeys && !$hasArray && is_array($v)) {
279-
$hasArray = true;
280-
}
319+
$hasArray = false;
320+
foreach ($data as &$value) {
321+
$value = self::getFormDataValue($value, $df, $cb);
322+
if (!$preserveKeys && !$hasArray && is_array($value)) {
323+
$hasArray = true;
281324
}
325+
}
326+
unset($value);
282327

283-
if ($hasArray) {
284-
$format = '[%s]';
285-
}
328+
if ($hasArray) {
329+
$format = '[%s]';
330+
}
286331

287-
self::doQuery($value, $flags, $df, $cb, $query, $key, $format);
288-
continue;
332+
/** @var object|mixed[]|string|null $value */
333+
foreach ($data as $key => $value) {
334+
$key = sprintf($format, $key);
335+
self::doFormData($value, $flags, $df, $cb, $query, $name . $key);
289336
}
290337

291-
$query[] = rawurlencode($key) . '=' . rawurlencode($value);
338+
return $query;
292339
}
293340

341+
$query[] = [$name, $data];
342+
294343
return $query;
295344
}
296345

297346
/**
347+
* @template T of object|mixed[]|string|null
348+
*
298349
* @param mixed $value
299-
* @param (callable(object): (object|mixed[]|string|null))|null $cb
300-
* @return mixed[]|string|null
350+
* @param (callable(object): (T|false))|null $cb
351+
* @return mixed[]|string|T
301352
*/
302-
private static function getQueryValue(
353+
private static function getFormDataValue(
303354
$value,
304355
DateFormatterInterface $df,
305-
?callable $cb,
306-
string $key
356+
?callable $cb
307357
) {
308358
if (is_bool($value)) {
309359
return (string) (int) $value;
@@ -317,41 +367,54 @@ private static function getQueryValue(
317367
return $value;
318368
}
319369

370+
if ($value instanceof DateTimeInterface) {
371+
return $df->format($value);
372+
}
373+
320374
if (is_object($value)) {
321-
if ($value instanceof DateTimeInterface) {
322-
return $df->format($value);
323-
}
324375
if ($cb !== null) {
325-
$value = $cb($value);
326-
if (!is_object($value)) {
327-
return $value;
376+
$result = $cb($value);
377+
if ($result instanceof DateTimeInterface) {
378+
return $df->format($result);
328379
}
329-
if ($value instanceof DateTimeInterface) {
330-
return $df->format($value);
380+
if ($result !== false) {
381+
return $result;
331382
}
332383
}
384+
333385
if ($value instanceof Arrayable) {
334386
return $value->toArray();
335387
}
336-
if ($value instanceof Jsonable) {
337-
return self::getQueryValue(
338-
Json::parseObjectAsArray($value->toJson()), $df, $cb, $key
339-
);
388+
389+
if ((
390+
$value instanceof JsonSerializable &&
391+
is_array($result = $value->jsonSerialize())
392+
) || (
393+
$value instanceof Jsonable &&
394+
is_array($result = Json::parseObjectAsArray($value->toJson()))
395+
)) {
396+
return $result;
397+
}
398+
399+
// Get public property values
400+
$result = [];
401+
// @phpstan-ignore-next-line
402+
foreach ($value as $key => $val) {
403+
$result[$key] = $val;
340404
}
341-
if (
342-
($value instanceof Stringable || method_exists($value, '__toString')) &&
343-
!($value instanceof JsonSerializable)
344-
) {
405+
if ($result !== []) {
406+
return $result;
407+
}
408+
409+
if (Test::isStringable($value)) {
345410
return (string) $value;
346411
}
347-
return self::getQueryValue(
348-
Json::parseObjectAsArray(Json::stringify($value)), $df, $cb, $key
349-
);
412+
413+
return [];
350414
}
351415

352416
throw new InvalidArgumentException(sprintf(
353-
'Invalid value at %s: %s',
354-
$key,
417+
'Invalid value: %s',
355418
self::type($value),
356419
));
357420
}
@@ -385,21 +448,21 @@ public static function coalesce(...$values)
385448
* @param Arrayable<TKey,TValue>|iterable<TKey,TValue> $value
386449
* @return array<TKey,TValue>
387450
*/
388-
public static function array($value, bool $preserveKeys = false): array
451+
public static function array($value): array
389452
{
390453
if (is_array($value)) {
391454
return $value;
392455
}
393456
if ($value instanceof Arrayable) {
394457
return $value->toArray();
395458
}
396-
return iterator_to_array($value, $preserveKeys);
459+
return iterator_to_array($value);
397460
}
398461

399462
/**
400463
* Resolve a value to an item count
401464
*
402-
* @param Traversable<array-key,mixed>|Arrayable<array-key,mixed>|Countable|array<array-key,mixed>|int $value
465+
* @param Arrayable<array-key,mixed>|iterable<array-key,mixed>|Countable|int $value
403466
*/
404467
public static function count($value): int
405468
{

0 commit comments

Comments
 (0)