Skip to content

Example: CORS ​

A notes API for a frontend at http://localhost:5173: GET and a JSON POST to /api/v1/notes.

Open the diagram at full size

Several origins

This example uses one fixed origin, which works in 3.5.0 and after. For more origins, 3.6.0 adds setCorsAllowedOrigins(). See CORS.

The services ​

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

parameters:
	# where the browser frontend runs
	frontendOrigin: 'http://localhost:5173'

services:
	router: App\RouterFactory::create

	- Tomaj\NetteApi\Output\Configurator\EnvConfigurator
	- Tomaj\NetteApi\Error\DefaultErrorHandler

	apiPresenter:
		factory: Tomaj\NetteApi\Presenters\ApiPresenter
		setup:
			# sent as Access-Control-Allow-Origin on every response.
			# Other values: '*' (the default), 'auto' (the origin of the Referer header,
			# with Access-Control-Allow-Credentials: true) and 'off' (no header)
			- setCorsHeader(%frontendOrigin%)

	apiDecider:
		factory: Tomaj\NetteApi\ApiDecider
		setup:
			- addApi(Tomaj\NetteApi\EndpointIdentifier('GET', 1, 'notes'), App\Api\NoteListHandler(), Tomaj\NetteApi\Authorization\NoAuthorization())
			- addApi(Tomaj\NetteApi\EndpointIdentifier('POST', 1, 'notes'), App\Api\NoteCreateHandler(), Tomaj\NetteApi\Authorization\NoAuthorization())
			# answer OPTIONS for every registered endpoint, so the browser may send
			# the Content-Type and Authorization headers
			- enableGlobalPreflight(
				Tomaj\NetteApi\Handlers\CorsPreflightHandler(headers: [
					Access-Control-Allow-Headers: [Content-Type, Authorization],
					Access-Control-Max-Age: '600'
				])
			)

The handler ​

php
<?php

declare(strict_types=1);

namespace App\Api;

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

final class NoteCreateHandler extends BaseHandler
{
    public function params(): array
    {
        return [
            (new JsonInputParam('note', '{"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}'))
                ->setRequired(),
        ];
    }

    public function handle(array $params): ResponseInterface
    {
        return new JsonApiResponse(201, ['note' => ['id' => 2, 'text' => $params['note']['text']]]);
    }
}

The test ​

bash
#!/usr/bin/env bash
# End-to-end check: the CORS headers a browser needs for a cross-origin GET and a JSON POST.
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 8084

request -H 'Origin: http://localhost:5173' "$BASE/api/v1/notes"
expect_status 200 "GET"
expect_header 'Access-Control-Allow-Origin: http://localhost:5173' "GET"
expect_header 'Access-Control-Allow-Methods: POST, DELETE, PUT, GET, OPTIONS' "GET"
ok "GET carries Access-Control-Allow-Origin for the frontend"

request -X OPTIONS -H 'Origin: http://localhost:5173' -H 'Access-Control-Request-Method: POST' \
    -H 'Access-Control-Request-Headers: content-type' "$BASE/api/v1/notes"
expect_status 200 "preflight"
expect_header 'Access-Control-Allow-Headers: Content-Type' "preflight"
expect_header 'Access-Control-Allow-Headers: Authorization' "preflight"
expect_header 'Access-Control-Max-Age: 600' "preflight"
ok "OPTIONS is answered by the global preflight handler"

request -X POST -H 'Origin: http://localhost:5173' -H 'Content-Type: application/json' -d '{"text":"Call mom"}' "$BASE/api/v1/notes"
expect_status 201 "POST"
expect_header 'Access-Control-Allow-Origin: http://localhost:5173' "POST"
ok "the JSON POST after the preflight succeeds"

request -X OPTIONS "$BASE/api/v1/unknown"
expect_status 404 "preflight for an unknown endpoint"
ok "OPTIONS for an endpoint that does not exist answers 404"

FAQ ​

Why does the frontend need a preflight? ​

A POST with Content-Type: application/json is not a simple request, so the browser asks with OPTIONS first.

Why is Content-Type in Allow-Headers? ​

The default preflight headers allow only Authorization and X-Requested-With; a JSON body needs Content-Type.

What does Max-Age 600 do? ​

The browser caches the preflight answer for 10 minutes.

Where do I set the origin per environment? ​

frontendOrigin is a NEON parameter; override it in your local or production config.

Does OPTIONS to an unknown URL work? ​

No, it answers 404, as the test shows. See CORS.

Released under the MIT License.