Skip to content

Commit 44ea2d6

Browse files
fix(client): properly generate file params
1 parent 71daf35 commit 44ea2d6

9 files changed

Lines changed: 162 additions & 24 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,36 @@ $client = new Client(requestOptions: ['maxRetries' => 0]);
145145
$result = $client->accounts->list(requestOptions: ['maxRetries' => 5]);
146146
```
147147

148+
### File uploads
149+
150+
Request parameters that correspond to file uploads can be passed as a resource returned by `fopen()`, a string of file contents, or a `FileParam` instance.
151+
152+
```php
153+
<?php
154+
155+
use BeeperDesktop\Core\FileParam;
156+
157+
// Pass a string with filename and content type:
158+
$contents = file_get_contents('/path/to/file');
159+
// Pass a string with filename and content type:
160+
$response = $client->assets->upload(
161+
file: FileParam::fromString($contents, filename: '/path/to/file', contentType: '…'),
162+
);
163+
164+
// Pass in only a string (where applicable)
165+
$response = $client->assets->upload(file: '…');
166+
167+
// Pass an open resource:
168+
$fd = fopen('/path/to/file', 'r');
169+
try {
170+
$response = $client->assets->upload(
171+
file: FileParam::fromResource($fd, filename: '/path/to/file', contentType: '…'),
172+
);
173+
} finally {
174+
fclose($fd);
175+
}
176+
```
177+
148178
## Advanced concepts
149179

150180
### Making custom or undocumented requests

src/Assets/AssetUploadParams.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99
use BeeperDesktop\Core\Concerns\SdkModel;
1010
use BeeperDesktop\Core\Concerns\SdkParams;
1111
use BeeperDesktop\Core\Contracts\BaseModel;
12+
use BeeperDesktop\Core\FileParam;
1213

1314
/**
1415
* Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending messages with attachments.
1516
*
1617
* @see BeeperDesktop\Services\AssetsService::upload()
1718
*
1819
* @phpstan-type AssetUploadParamsShape = array{
19-
* file: string, fileName?: string|null, mimeType?: string|null
20+
* file: string|FileParam, fileName?: string|null, mimeType?: string|null
2021
* }
2122
*/
2223
final class AssetUploadParams implements BaseModel
@@ -68,7 +69,7 @@ public function __construct()
6869
* You must use named parameters to construct any parameters with a default value.
6970
*/
7071
public static function with(
71-
string $file,
72+
string|FileParam $file,
7273
?string $fileName = null,
7374
?string $mimeType = null
7475
): self {
@@ -85,7 +86,7 @@ public static function with(
8586
/**
8687
* The file to upload (max 500 MB).
8788
*/
88-
public function withFile(string $file): self
89+
public function withFile(string|FileParam $file): self
8990
{
9091
$self = clone $this;
9192
$self['file'] = $file;

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 BeeperDesktop\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";

src/ServiceContracts/AssetsContract.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use BeeperDesktop\Assets\AssetUploadBase64Response;
99
use BeeperDesktop\Assets\AssetUploadResponse;
1010
use BeeperDesktop\Core\Exceptions\APIException;
11+
use BeeperDesktop\Core\FileParam;
1112
use BeeperDesktop\RequestOptions;
1213

1314
/**
@@ -44,15 +45,15 @@ public function serve(
4445
/**
4546
* @api
4647
*
47-
* @param string $file the file to upload (max 500 MB)
48+
* @param string|FileParam $file the file to upload (max 500 MB)
4849
* @param string $fileName Original filename. Defaults to the uploaded file name if omitted
4950
* @param string $mimeType MIME type. Auto-detected from magic bytes if omitted
5051
* @param RequestOpts|null $requestOptions
5152
*
5253
* @throws APIException
5354
*/
5455
public function upload(
55-
string $file,
56+
string|FileParam $file,
5657
?string $fileName = null,
5758
?string $mimeType = null,
5859
RequestOptions|array|null $requestOptions = null,

src/Services/AssetsRawService.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use BeeperDesktop\Client;
1515
use BeeperDesktop\Core\Contracts\BaseResponse;
1616
use BeeperDesktop\Core\Exceptions\APIException;
17+
use BeeperDesktop\Core\FileParam;
1718
use BeeperDesktop\RequestOptions;
1819
use BeeperDesktop\ServiceContracts\AssetsRawContract;
1920

@@ -98,7 +99,7 @@ public function serve(
9899
* Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending messages with attachments.
99100
*
100101
* @param array{
101-
* file: string, fileName?: string, mimeType?: string
102+
* file: string|FileParam, fileName?: string, mimeType?: string
102103
* }|AssetUploadParams $params
103104
* @param RequestOpts|null $requestOptions
104105
*

src/Services/AssetsService.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use BeeperDesktop\Assets\AssetUploadResponse;
1010
use BeeperDesktop\Client;
1111
use BeeperDesktop\Core\Exceptions\APIException;
12+
use BeeperDesktop\Core\FileParam;
1213
use BeeperDesktop\Core\Util;
1314
use BeeperDesktop\RequestOptions;
1415
use BeeperDesktop\ServiceContracts\AssetsContract;
@@ -82,15 +83,15 @@ public function serve(
8283
*
8384
* Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending messages with attachments.
8485
*
85-
* @param string $file the file to upload (max 500 MB)
86+
* @param string|FileParam $file the file to upload (max 500 MB)
8687
* @param string $fileName Original filename. Defaults to the uploaded file name if omitted
8788
* @param string $mimeType MIME type. Auto-detected from magic bytes if omitted
8889
* @param RequestOpts|null $requestOptions
8990
*
9091
* @throws APIException
9192
*/
9293
public function upload(
93-
string $file,
94+
string|FileParam $file,
9495
?string $fileName = null,
9596
?string $mimeType = null,
9697
RequestOptions|array|null $requestOptions = null,

tests/Services/AssetsTest.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use BeeperDesktop\Assets\AssetUploadBase64Response;
77
use BeeperDesktop\Assets\AssetUploadResponse;
88
use BeeperDesktop\Client;
9+
use BeeperDesktop\Core\FileParam;
910
use BeeperDesktop\Core\Util;
1011
use PHPUnit\Framework\Attributes\CoversNothing;
1112
use PHPUnit\Framework\Attributes\Test;
@@ -72,7 +73,9 @@ public function testServeWithOptionalParams(): void
7273
#[Test]
7374
public function testUpload(): void
7475
{
75-
$result = $this->client->assets->upload(file: 'file');
76+
$result = $this->client->assets->upload(
77+
file: FileParam::fromString('Example data', filename: uniqid('file-upload-', true)),
78+
);
7679

7780
// @phpstan-ignore-next-line method.alreadyNarrowedType
7881
$this->assertInstanceOf(AssetUploadResponse::class, $result);
@@ -82,9 +85,9 @@ public function testUpload(): void
8285
public function testUploadWithOptionalParams(): void
8386
{
8487
$result = $this->client->assets->upload(
85-
file: 'file',
88+
file: FileParam::fromString('Example data', filename: uniqid('file-upload-', true)),
8689
fileName: 'fileName',
87-
mimeType: 'mimeType'
90+
mimeType: 'mimeType',
8891
);
8992

9093
// @phpstan-ignore-next-line method.alreadyNarrowedType

0 commit comments

Comments
 (0)