Skip to content

Example: file upload ​

POST /api/v1/avatar takes one image and a user name. POST /api/v1/attachments takes many files in one field.

Open the diagram at full size
UploadResponse
a PNG under 100 kB201 with the stored name
no file400 Field is required
over 100 kB413
a text file sent as image/png415 text/plain is not allowed
three files in files[]200 with three entries

The handler for one file ​

$params['avatar'] is the $_FILES entry: name, type, tmp_name, error and size.

php
<?php

declare(strict_types=1);

namespace App\Api;

use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Params\FileInputParam;
use Tomaj\NetteApi\Params\PostInputParam;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;

final class AvatarUploadHandler extends BaseHandler
{
    private const MAX_BYTES = 100_000;

    private const TYPES = ['image/png' => 'png', 'image/jpeg' => 'jpg'];

    public function __construct(private string $storageDir)
    {
        parent::__construct();
    }

    public function summary(): string
    {
        return 'Upload an avatar';
    }

    public function params(): array
    {
        return [
            // $_FILES['avatar']: name, type, tmp_name, error, size
            (new FileInputParam('avatar'))->setRequired()->setDescription('PNG or JPEG, at most 100 kB'),
            (new PostInputParam('user'))->setRequired(),
        ];
    }

    public function handle(array $params): ResponseInterface
    {
        $file = $params['avatar'];

        // nette-api checks only that the file field is there; everything else is up to the handler
        if ($file['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) {
            return new JsonApiResponse(400, ['status' => 'error', 'message' => 'Upload failed']);
        }
        if ($file['size'] > self::MAX_BYTES) {
            return new JsonApiResponse(413, ['status' => 'error', 'message' => 'The file is larger than 100 kB']);
        }

        // never trust $file['type'], it comes from the client
        $type = (string) (new \finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
        if (!isset(self::TYPES[$type])) {
            return new JsonApiResponse(415, ['status' => 'error', 'message' => "Type $type is not allowed"]);
        }

        // never use $file['name'] as a path either
        $target = $this->storageDir . '/' . bin2hex(random_bytes(8)) . '.' . self::TYPES[$type];
        move_uploaded_file($file['tmp_name'], $target);

        return new JsonApiResponse(201, ['user' => $params['user'], 'type' => $type, 'size' => $file['size']]);
    }
}

Many files ​

setMulti() makes the param a list. The handler receives one entry per file.

php
<?php

declare(strict_types=1);

namespace App\Api;

use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Params\FileInputParam;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;

final class AttachmentsUploadHandler extends BaseHandler
{
    public function summary(): string
    {
        return 'Upload several files at once';
    }

    public function params(): array
    {
        return [
            // files[]=@a.txt files[]=@b.txt; setMulti() turns $_FILES into one array per file
            (new FileInputParam('files'))->setMulti()->setRequired(),
        ];
    }

    public function handle(array $params): ResponseInterface
    {
        $files = [];
        foreach ($params['files'] as $file) {
            $files[] = ['name' => basename($file['name']), 'size' => $file['size'], 'ok' => $file['error'] === UPLOAD_ERR_OK];
        }

        return new JsonApiResponse(200, ['files' => $files]);
    }
}

The services ​

yaml
application:
	mapping:
		Api: Tomaj\NetteApi\Presenters\*Presenter

services:
	router: App\RouterFactory::create
	apiPresenter:
		factory: Tomaj\NetteApi\Presenters\ApiPresenter
	- Tomaj\NetteApi\Output\Configurator\EnvConfigurator
	- Tomaj\NetteApi\Error\DefaultErrorHandler

	apiDecider:
		factory: Tomaj\NetteApi\ApiDecider
		setup:
			# POST /api/v1/avatar, multipart/form-data with avatar and user
			- addApi(Tomaj\NetteApi\EndpointIdentifier('POST', 1, 'avatar'), App\Api\AvatarUploadHandler(%tempDir%), Tomaj\NetteApi\Authorization\NoAuthorization())
			# POST /api/v1/attachments, multipart/form-data with files[]
			- addApi(Tomaj\NetteApi\EndpointIdentifier('POST', 1, 'attachments'), App\Api\AttachmentsUploadHandler(), Tomaj\NetteApi\Authorization\NoAuthorization())

The test ​

bash
#!/usr/bin/env bash
# End-to-end check: one file and many files sent as multipart/form-data.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f vendor/autoload.php ]] || { echo "fail: vendor/ is missing, run composer install first" >&2; exit 1; }
source ../ci/lib.sh
serve 8092
files="$(mktemp -d)"
# a 1x1 PNG, a text file and a 200 kB file
printf '\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\1\0\0\0\1\x08\x06\0\0\0\x1f\x15\xc4\x89\0\0\0\rIDATx\x9cc\xf8\x0f\0\0\x01\x01\0\x05\x18\xd8N\0\0\0\0IEND\xaeB`\x82' > "$files/a.png"
echo 'hello' > "$files/a.txt"
head -c 200000 /dev/zero > "$files/big.png"

request -F "avatar=@$files/a.png" -F user=ada "$BASE/api/v1/avatar"
expect_status 201 "png"
expect_body '"user":"ada","type":"image/png"' "png"
ok "a PNG is stored, the type is detected from the content"

request -F "avatar=@$files/a.txt;type=image/png" -F user=ada "$BASE/api/v1/avatar"
expect_status 415 "fake type"
expect_body 'text/plain is not allowed' "fake type"
ok "a text file sent as image/png answers 415"

request -F "avatar=@$files/big.png" -F user=ada "$BASE/api/v1/avatar"
expect_status 413 "too big"
ok "a file over 100 kB answers 413"

request -F user=ada "$BASE/api/v1/avatar"
expect_status 400 "no file"
expect_body '"avatar":["Field is required"]' "no file"
ok "a missing file answers 400 from nette-api"

request -F "files[]=@$files/a.txt" -F "files[]=@$files/a.png" "$BASE/api/v1/attachments"
expect_status 200 "many"
expect_body '{"files":[{"name":"a.txt","size":6,"ok":true},{"name":"a.png"' "many"
ok "setMulti() gives one array per uploaded file"
rm -rf "$files"

FAQ ​

Why not trust the type field? ​

The client sends it. finfo looks at the bytes of the file instead.

Why a random file name? ​

The client's name can contain ../ or overwrite another file. Keep the original name in your database if you need it.

Where is the size limit? ​

In the handler, and in PHP: upload_max_filesize and post_max_size must be at least as large, otherwise PHP drops the file before the handler sees it.

Does it work with PUT? ​

No, PHP fills $_FILES only for POST multipart requests. For PUT, send the file as the raw body, see JSON and raw body.

How does the console send files? ​

It shows a file input for each FileInputParam. See Web console.

Released under the MIT License.