Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(php): Design multipart/form-data requests #4728

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

amckinney
Copy link
Contributor

This outlines an approach to support multipart/form-data requests with the following new types:

  • Core\MultipartApiRequest
  • Core\MultipartFormData
  • Core\MultipartFormDataPart
  • Utils\File (user-facing)

References:

  • Guzzle multipart requests (link)

UX

Users are presented with a generated request type that specifies the Utils\File type alongside any other in-lined request parameters.

<?php

namespace Seed\Service\Requests;

use Acme\Utils\File;

class FileUploadRequest
{
    /**
     * @var File $file
     */
    public File $file;

    /**
     * @var User $user
     */
    public User $user;

    /**
     * @param array{
     *   file: File,
     *   user: User,
     * } $values
     */
    public function __construct(
        array $values,
    ) {
        $this->file = $values['file'];
        $this->user = $values['user'];
    }
}

With this, users can create the File type by using the primary constructor or any of the other static constructor helper methods, and call the method like so:

$client->files->upload(
  new FileUploadRequest(
    [
      'file' => File::createFromFilepath('./path/to/example.png'),
      'user' => new User(['name' => 'john'])
    ]
  )
);

Implementation

Behind the scenes, the new Utils\File type includes a mapper method for the Core\MultipartFormDataPart:

/**
 * 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,
    );
}

The MultipartFormDataPart is a class used to represent individual multipart form data elements in the Guzzle API (docs). It includes a toArray method that maps into this form that looks like the following:

/**
 * 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<string, string>
 * }
 */
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;
}

The MultipartFormData is simply a collection of MultipartFormDataPart elements with additional methods for constructing and adding these parts. It is mapped to Guzzle's representation with another toArray method that looks like the following:

/**
 * Converts the multipart form data into an array suitable
 * for Guzzle's multipart form data.
 *
 * @return array<array{
 *     name: string,
 *     contents: StreamInterface,
 *     filename?: string,
 *     headers?: array<string, string>
 * }>
 */
public function toArray(): array
{
    return array_map(fn($part) => $part->toArray(), $this->parts);
}

Finally, the endpoint is implemented so that we compose a MultipartFormData element, and individually add parts based on their wire name (e.g. 'number' for the number property):

/**
 * @param UploadFileRequest $request
 * @param ?array{
 *   baseUrl?: string,
 * } $options
 * @throws AcmeException
 * @throws AcmeApiException
 */
public function uploadFIle(UploadFileRequest $request, ?array $options = null): void
{
    try {
        $body = new MultipartFormData();
        $body->addPart($request->file->toMultipartFormDataPart('file'));
        $body->add('user', $request->user->toJson());
    } catch (Exception $e) {
        throw new AcmeException(message: $e->getMessage(), previous: $e);
    }

    try {
        $response = $this->client->sendRequest(
            new JsonApiRequest(
                baseUrl: $this->options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '',
                path: "/file/upload",
                method: HttpMethod::POST,
                body: $body,
            ),
        );
        $statusCode = $response->getStatusCode();
        if ($statusCode >= 200 && $statusCode < 400) {
            return;
        }
    } catch (ClientExceptionInterface $e) {
        throw new AcmeException(message: $e->getMessage(), previous: $e);
    }
    throw new AcmeApiException(
        message: 'API request failed',
        statusCode: $statusCode,
        body: $response->getBody()->getContents(),
    );
}

Copy link
Contributor

@dcb6 dcb6 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left a couple small comments! i don't have much experience with file upload to pull from, but all of this looks great from where I stand 😊

@amckinney amckinney changed the title feat(php): Support multipart/form-data requests feat(php): Design multipart/form-data requests Sep 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

2 participants