diff --git a/seed/php-sdk/file-upload/src/Core/MultipartApiRequest.php b/seed/php-sdk/file-upload/src/Core/MultipartApiRequest.php new file mode 100644 index 00000000000..38df42ad366 --- /dev/null +++ b/seed/php-sdk/file-upload/src/Core/MultipartApiRequest.php @@ -0,0 +1,27 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + * @param ?MultipartFormData $body The multipart form data for the request (optional) + */ + public function __construct( + string $baseUrl, + string $path, + HttpMethod $method, + array $headers = [], + array $query = [], + public readonly ?MultipartFormData $body = null + ) + { + parent::__construct($baseUrl, $path, $method, $headers, $query); + } +} diff --git a/seed/php-sdk/file-upload/src/Core/MultipartFormData.php b/seed/php-sdk/file-upload/src/Core/MultipartFormData.php new file mode 100644 index 00000000000..24542346bd1 --- /dev/null +++ b/seed/php-sdk/file-upload/src/Core/MultipartFormData.php @@ -0,0 +1,62 @@ + + */ + private array $parts = []; + + /** + * Adds a new part to the multipart form data. + * + * @param string $name + * @param string|int|bool|float|StreamInterface $value + * @param ?string $contentType + */ + public function add( + string $name, + string|int|bool|float|StreamInterface $value, + ?string $contentType = null, + ): void + { + $headers = $contentType != null ? ['Content-Type' => $contentType] : null; + self::addPart( + new MultipartFormDataPart( + name: $name, + contents: $value, + headers: $headers, + ) + ); + } + + /** + * Adds a new part to the multipart form data. + * + * @param MultipartFormDataPart $part + */ + public function addPart(MultipartFormDataPart $part): void + { + $this->parts[] = $part; + } + + /** + * Converts the multipart form data into an array suitable + * for Guzzle's multipart form data. + * + * @return array + * }> + */ + public function toArray(): array + { + return array_map(fn($part) => $part->toArray(), $this->parts); + } +} diff --git a/seed/php-sdk/file-upload/src/Core/MultipartFormDataPart.php b/seed/php-sdk/file-upload/src/Core/MultipartFormDataPart.php new file mode 100644 index 00000000000..5af660590c0 --- /dev/null +++ b/seed/php-sdk/file-upload/src/Core/MultipartFormDataPart.php @@ -0,0 +1,76 @@ + + */ + private ?array $headers; + + /** + * @param string $name + * @param string|bool|float|int|StreamInterface $contents + * @param ?string $filename + * @param ?array $headers + */ + public function __construct( + string $name, + string|bool|float|int|StreamInterface $contents, + ?string $filename = null, + ?array $headers = null + ) { + $this->name = $name; + $this->contents = Utils::streamFor($contents); + $this->filename = $filename; + $this->headers = $headers; + } + + /** + * Converts the multipart form data part into an array suitable + * for Guzzle's multipart form data. + * + * @return array{ + * name: string, + * contents: StreamInterface, + * filename?: string, + * headers?: array + * } + */ + public function toArray(): array + { + $formData = [ + 'name' => $this->name, + 'contents' => $this->contents, + ]; + + if ($this->filename != null) { + $formData['filename'] = $this->filename; + } + + if ($this->headers != null) { + $formData['headers'] = $this->headers; + } + + return $formData; + } +} \ No newline at end of file diff --git a/seed/php-sdk/file-upload/src/Core/RawClient.php b/seed/php-sdk/file-upload/src/Core/RawClient.php index 1c0e42bf650..ea9ffad01b8 100644 --- a/seed/php-sdk/file-upload/src/Core/RawClient.php +++ b/seed/php-sdk/file-upload/src/Core/RawClient.php @@ -4,6 +4,7 @@ use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; +use GuzzleHttp\Psr7\MultipartStream; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Utils; use InvalidArgumentException; @@ -73,6 +74,11 @@ private function encodeHeaders( $this->headers, $request->headers ), + MultipartApiRequest::class => array_merge( + ["Content-Type" => "multipart/form-data"], + $this->headers, + $request->headers + ), default => throw new InvalidArgumentException('Unsupported request type: ' . get_class($request)), }; } @@ -82,6 +88,7 @@ private function encodeRequestBody( ): ?StreamInterface { return match (get_class($request)) { JsonApiRequest::class => $request->body != null ? Utils::streamFor(json_encode($request->body)) : null, + MultipartApiRequest::class => $request->body != null ? new MultipartStream($request->body->toArray()) : null, default => throw new InvalidArgumentException('Unsupported request type: '.get_class($request)), }; } diff --git a/seed/php-sdk/file-upload/src/Service/Requests/JustFileRequet.php b/seed/php-sdk/file-upload/src/Service/Requests/JustFileRequet.php index e5aa46978ba..2baf4548714 100644 --- a/seed/php-sdk/file-upload/src/Service/Requests/JustFileRequet.php +++ b/seed/php-sdk/file-upload/src/Service/Requests/JustFileRequet.php @@ -2,6 +2,23 @@ namespace Seed\Service\Requests; +use Seed\Utils\File; + class JustFileRequet { + /** + * @var File $foo + */ + public File $file; + + /** + * @param array{ + * file: File, + * } $values + */ + public function __construct( + array $values, + ) { + $this->file = $values['file']; + } } diff --git a/seed/php-sdk/file-upload/src/Service/Requests/JustFileWithQueryParamsRequet.php b/seed/php-sdk/file-upload/src/Service/Requests/JustFileWithQueryParamsRequet.php index 0f84cbed492..77fa7b6317e 100644 --- a/seed/php-sdk/file-upload/src/Service/Requests/JustFileWithQueryParamsRequet.php +++ b/seed/php-sdk/file-upload/src/Service/Requests/JustFileWithQueryParamsRequet.php @@ -2,6 +2,8 @@ namespace Seed\Service\Requests; +use Seed\Utils\File; + class JustFileWithQueryParamsRequet { /** @@ -29,6 +31,11 @@ class JustFileWithQueryParamsRequet */ public array $optionalListOfStrings; + /** + * @var File $file + */ + public File $file; + /** * @param array{ * maybeString?: ?string, @@ -36,6 +43,7 @@ class JustFileWithQueryParamsRequet * maybeInteger?: ?int, * listOfStrings: array, * optionalListOfStrings: array, + * file: File, * } $values */ public function __construct( @@ -46,5 +54,6 @@ public function __construct( $this->maybeInteger = $values['maybeInteger'] ?? null; $this->listOfStrings = $values['listOfStrings']; $this->optionalListOfStrings = $values['optionalListOfStrings']; + $this->file = $values['file']; } } diff --git a/seed/php-sdk/file-upload/src/Service/Requests/MyRequest.php b/seed/php-sdk/file-upload/src/Service/Requests/MyRequest.php index b0ac95c364b..e4fc64e1cbc 100644 --- a/seed/php-sdk/file-upload/src/Service/Requests/MyRequest.php +++ b/seed/php-sdk/file-upload/src/Service/Requests/MyRequest.php @@ -2,6 +2,102 @@ namespace Seed\Service\Requests; +use Seed\Utils\File; +use Seed\Service\Types\MyObject; +use Seed\Service\Types\ObjectType; + class MyRequest { -} + /** + * @var ?string $maybeString + */ + public ?string $maybeString; + + /** + * @var int $integer + */ + public int $integer; + + /** + * @var File $file + */ + public File $file; + + /** + * @var array $fileList + */ + public array $fileList; + + /** + * @var ?File $maybeFile + */ + public ?File $maybeFile; + + /** + * @var ?array $maybeFileList + */ + public ?array $maybeFileList; + + /** + * @var ?int $maybeInteger + */ + public ?int $maybeInteger; + + /** + * @var ?array $optionalListOfStrings + */ + public ?array $optionalListOfStrings; + + /** + * @var array $listOfObjects + */ + public array $listOfObjects; + + /** + * @var mixed $optionalMetadata + */ + public $optionalMetadata; + + /** + * @var ?ObjectType $optionalObjectType + */ + public ?ObjectType $optionalObjectType; + + /** + * @var ?string $optionalId + */ + public ?string $optionalId; + + /** + * @param array{ + * maybeString?: ?string, + * integer: int, + * file: File, + * fileList: array, + * maybeFile?: ?File, + * maybeFileList?: ?array, + * maybeInteger?: ?int, + * optionalListOfStrings?: ?array, + * listOfObjects: array, + * optionalMetadata?: mixed, + * optionalObjectType?: ?ObjectType, + * optionalId?: ?string, + * } $values + */ + public function __construct( + array $values, + ) { + $this->maybeString = $values['maybeString'] ?? null; + $this->integer = $values['integer']; + $this->file = $values['file']; + $this->fileList = $values['fileList']; + $this->maybeFile = $values['maybeFile'] ?? null; + $this->maybeFileList = $values['maybeFileList'] ?? null; + $this->maybeInteger = $values['maybeInteger'] ?? null; + $this->optionalListOfStrings = $values['optionalListOfStrings'] ?? null; + $this->listOfObjects = $values['listOfObjects']; + $this->optionalMetadata = $values['optionalMetadata'] ?? null; + $this->optionalObjectType = $values['optionalObjectType'] ?? null; + $this->optionalId = $values['optionalId'] ?? null; + } +} \ No newline at end of file diff --git a/seed/php-sdk/file-upload/src/Service/Requests/WithContentTypeRequest.php b/seed/php-sdk/file-upload/src/Service/Requests/WithContentTypeRequest.php index f59b677f4f2..60bd616f822 100644 --- a/seed/php-sdk/file-upload/src/Service/Requests/WithContentTypeRequest.php +++ b/seed/php-sdk/file-upload/src/Service/Requests/WithContentTypeRequest.php @@ -2,6 +2,38 @@ namespace Seed\Service\Requests; +use Seed\Utils\File; +use Seed\Service\Types\MyObject; + class WithContentTypeRequest { + /** + * @var File $file + */ + public File $file; + + /** + * @var string $foo + */ + public string $foo; + + /** + * @var MyObject $bar + */ + public MyObject $bar; + + /** + * @param array{ + * file: File, + * foo: string, + * bar: MyObject, + * } $values + */ + public function __construct( + array $values, + ) { + $this->file = $values['file']; + $this->foo = $values['foo']; + $this->bar = $values['bar']; + } } diff --git a/seed/php-sdk/file-upload/src/Service/ServiceClient.php b/seed/php-sdk/file-upload/src/Service/ServiceClient.php index e9fa9a8e081..cf1c0d7135e 100644 --- a/seed/php-sdk/file-upload/src/Service/ServiceClient.php +++ b/seed/php-sdk/file-upload/src/Service/ServiceClient.php @@ -2,6 +2,10 @@ namespace Seed\Service; +use Exception; +use Seed\Core\MultipartApiRequest; +use Seed\Core\MultipartFormData; +use Seed\Core\MultipartFormDataPart; use Seed\Core\RawClient; use Seed\Service\Requests\MyRequest; use Seed\Exceptions\SeedException; @@ -39,12 +43,31 @@ public function __construct( */ public function post(MyRequest $request, ?array $options = null): void { + $body = new MultipartFormData(); + if ($request->maybeString != null) { + $body->add(name: 'maybeString', value: $request->maybeString); + } + $body->add(name: 'integer', value: $request->integer); + $body->addPart($request->file->toMultipartFormDataPart('file')); + foreach ($request->fileList as $file) { + $body->addPart($file->toMultipartFormDataPart('fileList')); + } + if ($request->maybeFile != null) { + $body->addPart($request->maybeFile->toMultipartFormDataPart('maybeFile')); + } + if ($request->maybeFileList != null) { + foreach ($request->fileList as $file) { + $body->addPart($file->toMultipartFormDataPart('maybeFileList')); + } + } + try { $response = $this->client->sendRequest( - new JsonApiRequest( + new MultipartApiRequest( baseUrl: $this->options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', path: "", method: HttpMethod::POST, + body: $body, ), ); $statusCode = $response->getStatusCode(); @@ -71,12 +94,16 @@ public function post(MyRequest $request, ?array $options = null): void */ public function justFile(JustFileRequet $request, ?array $options = null): void { + $body = new MultipartFormData(); + $body->addPart($request->file->toMultipartFormDataPart('file')); + try { $response = $this->client->sendRequest( new JsonApiRequest( baseUrl: $this->options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', path: "/just-file", method: HttpMethod::POST, + body: $body, ), ); $statusCode = $response->getStatusCode(); @@ -115,6 +142,10 @@ public function justFileWithQueryParams(JustFileWithQueryParamsRequet $request, if ($request->optionalListOfStrings != null) { $query['optionalListOfStrings'] = $request->optionalListOfStrings; } + + $body = new MultipartFormData(); + $body->addPart($request->file->toMultipartFormDataPart('file')); + try { $response = $this->client->sendRequest( new JsonApiRequest( @@ -122,6 +153,7 @@ public function justFileWithQueryParams(JustFileWithQueryParamsRequet $request, path: "/just-file-with-query-params", method: HttpMethod::POST, query: $query, + body: $body, ), ); $statusCode = $response->getStatusCode(); @@ -148,12 +180,27 @@ public function justFileWithQueryParams(JustFileWithQueryParamsRequet $request, */ public function withContentType(WithContentTypeRequest $request, ?array $options = null): void { + try { + $body = new MultipartFormData(); + $body->addPart( + $request->file->toMultipartFormDataPart( + name: 'file', + contentType:'application/octet-stream', + ), + ); + $body->add('foo', $request->foo); + $body->add('bar', $request->bar->toJson()); + } catch (Exception $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + try { $response = $this->client->sendRequest( new JsonApiRequest( baseUrl: $this->options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', path: "/with-content-type", method: HttpMethod::POST, + body: $body, ), ); $statusCode = $response->getStatusCode(); diff --git a/seed/php-sdk/file-upload/src/Utils/File.php b/seed/php-sdk/file-upload/src/Utils/File.php new file mode 100644 index 00000000000..098702d0a62 --- /dev/null +++ b/seed/php-sdk/file-upload/src/Utils/File.php @@ -0,0 +1,125 @@ +filename = $filename; + $this->contentType = $contentType; + $this->stream = $stream; + } + + /** + * Creates a File instance from a filepath. + * + * @param string $filepath + * @param ?string $filename + * @param ?string $contentType + * @return File + * @throws Exception + */ + public static function createFromFilepath( + string $filepath, + ?string $filename = null, + ?string $contentType = null, + ): File { + $resource = fopen($filepath, 'r'); + if (!$resource) { + throw new Exception("Unable to open file $filepath"); + } + $stream = Utils::streamFor($resource); + if (!$stream->isReadable()) { + throw new Exception("File $filepath is not readable"); + } + return new self( + stream: $stream, + filename: $filename ?? basename($filepath), + contentType: $contentType, + ); + } + + /** + * Creates a File instance from a string. + * + * @param string $content + * @param ?string $filename + * @param ?string $contentType + * @return File + */ + public static function createFromString( + string $content, + ?string $filename, + ?string $contentType = null, + ): File { + return new self( + stream: Utils::streamFor($content), + filename: $filename, + contentType: $contentType, + ); + } + + /** + * Maps this File into a multipart form data part. + * + * @param string $name The name of the mutlipart form data part. + * @param ?string $contentType Overrides the Content-Type associated with the file, if any. + * @return MultipartFormDataPart + */ + public function toMultipartFormDataPart(string $name, ?string $contentType = null): MultipartFormDataPart + { + $contentType ??= $this->contentType; + $headers = $contentType != null + ? ['Content-Type' => $contentType] + : null; + + return new MultipartFormDataPart( + name: $name, + contents: $this->stream, + filename: $this->filename, + headers: $headers, + ); + } + + /** + * Closes the file stream. + */ + public function close(): void + { + $this->stream->close(); + } + + /** + * Destructor to ensure stream is closed. + */ + public function __destruct() + { + $this->close(); + } +}