Skip to content

Commit 4a6d856

Browse files
fix(client): properly generate file params
1 parent 1f99ac3 commit 4a6d856

File tree

3 files changed

+114
-13
lines changed

3 files changed

+114
-13
lines changed

src/Core/Conversion.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ public static function dump_unknown(mixed $value, DumpState $state): mixed
2121
}
2222

2323
if (is_object($value)) {
24+
if ($value instanceof FileParam) {
25+
return $value;
26+
}
27+
2428
if (is_a($value, class: ConverterSource::class)) {
2529
return $value::converter()->dump($value, state: $state);
2630
}

src/Core/FileParam.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Stagehand\Core;
6+
7+
/**
8+
* Represents a file to upload in a multipart request.
9+
*
10+
* ```php
11+
* // From a file on disk:
12+
* $client->files->upload(file: FileParam::fromResource(fopen('data.csv', 'r')));
13+
*
14+
* // From a string:
15+
* $client->files->upload(file: FileParam::fromString('csv data...', 'data.csv'));
16+
* ```
17+
*/
18+
final class FileParam
19+
{
20+
public const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
21+
22+
/**
23+
* @param resource|string $data the file content as a resource or string
24+
*/
25+
private function __construct(
26+
public readonly mixed $data,
27+
public readonly string $filename,
28+
public readonly string $contentType = self::DEFAULT_CONTENT_TYPE,
29+
) {}
30+
31+
/**
32+
* Create a FileParam from an open resource (e.g. from fopen()).
33+
*
34+
* @param resource $resource an open file resource
35+
* @param string|null $filename Override the filename. Defaults to the resource URI basename.
36+
* @param string $contentType override the content type
37+
*/
38+
public static function fromResource(mixed $resource, ?string $filename = null, string $contentType = self::DEFAULT_CONTENT_TYPE): self
39+
{
40+
if (!is_resource($resource)) {
41+
throw new \InvalidArgumentException('Expected a resource, got '.get_debug_type($resource));
42+
}
43+
44+
if (null === $filename) {
45+
$meta = stream_get_meta_data($resource);
46+
$filename = basename($meta['uri'] ?? 'upload');
47+
}
48+
49+
return new self($resource, filename: $filename, contentType: $contentType);
50+
}
51+
52+
/**
53+
* Create a FileParam from a string.
54+
*
55+
* @param string $content the file content
56+
* @param string $filename the filename for the Content-Disposition header
57+
* @param string $contentType override the content type
58+
*/
59+
public static function fromString(string $content, string $filename, string $contentType = self::DEFAULT_CONTENT_TYPE): self
60+
{
61+
return new self($content, filename: $filename, contentType: $contentType);
62+
}
63+
}

src/Core/Util.php

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ public static function withSetBody(
283283

284284
if (preg_match('/^multipart\/form-data/', $contentType)) {
285285
[$boundary, $gen] = self::encodeMultipartStreaming($body);
286-
$encoded = implode('', iterator_to_array($gen));
286+
$encoded = implode('', iterator_to_array($gen, preserve_keys: false));
287287
$stream = $factory->createStream($encoded);
288288

289289
/** @var RequestInterface */
@@ -447,11 +447,18 @@ private static function writeMultipartContent(
447447
): \Generator {
448448
$contentLine = "Content-Type: %s\r\n\r\n";
449449

450-
if (is_resource($val)) {
451-
yield sprintf($contentLine, $contentType ?? 'application/octet-stream');
452-
while (!feof($val)) {
453-
if ($read = fread($val, length: self::BUF_SIZE)) {
454-
yield $read;
450+
if ($val instanceof FileParam) {
451+
$ct = $val->contentType ?? $contentType;
452+
453+
yield sprintf($contentLine, $ct);
454+
$data = $val->data;
455+
if (is_string($data)) {
456+
yield $data;
457+
} else { // resource
458+
while (!feof($data)) {
459+
if ($read = fread($data, length: self::BUF_SIZE)) {
460+
yield $read;
461+
}
455462
}
456463
}
457464
} elseif (is_string($val) || is_numeric($val) || is_bool($val)) {
@@ -483,17 +490,48 @@ private static function writeMultipartChunk(
483490
yield 'Content-Disposition: form-data';
484491

485492
if (!is_null($key)) {
486-
$name = rawurlencode(self::strVal($key));
493+
$name = str_replace(['"', "\r", "\n"], replace: '', subject: $key);
487494

488495
yield "; name=\"{$name}\"";
489496
}
490497

498+
// File uploads require a filename in the Content-Disposition header,
499+
// e.g. `Content-Disposition: form-data; name="file"; filename="data.csv"`
500+
// Without this, many servers will reject the upload with a 400.
501+
if ($val instanceof FileParam) {
502+
$filename = str_replace(['"', "\r", "\n"], replace: '', subject: $val->filename);
503+
504+
yield "; filename=\"{$filename}\"";
505+
}
506+
491507
yield "\r\n";
492508
foreach (self::writeMultipartContent($val, closing: $closing) as $chunk) {
493509
yield $chunk;
494510
}
495511
}
496512

513+
/**
514+
* Expands list arrays into separate multipart parts, applying the configured array key format.
515+
*
516+
* @param list<callable> $closing
517+
*
518+
* @return \Generator<string>
519+
*/
520+
private static function writeMultipartField(
521+
string $boundary,
522+
?string $key,
523+
mixed $val,
524+
array &$closing
525+
): \Generator {
526+
if (is_array($val) && array_is_list($val)) {
527+
foreach ($val as $item) {
528+
yield from self::writeMultipartField(boundary: $boundary, key: $key, val: $item, closing: $closing);
529+
}
530+
} else {
531+
yield from self::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing);
532+
}
533+
}
534+
497535
/**
498536
* @param bool|int|float|string|resource|\Traversable<mixed,>|array<string,mixed>|null $body
499537
*
@@ -508,14 +546,10 @@ private static function encodeMultipartStreaming(mixed $body): array
508546
try {
509547
if (is_array($body) || is_object($body)) {
510548
foreach ((array) $body as $key => $val) {
511-
foreach (static::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing) as $chunk) {
512-
yield $chunk;
513-
}
549+
yield from static::writeMultipartField(boundary: $boundary, key: $key, val: $val, closing: $closing);
514550
}
515551
} else {
516-
foreach (static::writeMultipartChunk(boundary: $boundary, key: null, val: $body, closing: $closing) as $chunk) {
517-
yield $chunk;
518-
}
552+
yield from static::writeMultipartField(boundary: $boundary, key: null, val: $body, closing: $closing);
519553
}
520554

521555
yield "--{$boundary}--\r\n";

0 commit comments

Comments
 (0)