diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..9b75b70ff39d76443d9186dba5050b6956e5ab48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +vendor/ +*.cache +composer.lock +reports/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..8307adff0fc180c83ec8786bfdd9f5a4a96b84d0 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,44 @@ +workflow: + rules: + - if: $CI_MERGE_REQUEST_IID + - if: $CI_COMMIT_TAG + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +variables: + PHP_IMAGE: php:7.2 + +cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - vendor/ + +before_script: + # Install composer + - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + # Install libraries needed by composer + - apt-get update && apt-get install -y git && apt-get install -y unzip + # Intall Xdebug + - pecl install xdebug-2.8.1 && docker-php-ext-enable xdebug + +stages: + - build + - test + +build: + stage: build + image: $PHP_IMAGE + script: + - composer install + +test: + stage: test + image: $PHP_IMAGE + script: + - composer run test:ci + artifacts: + when: always + reports: + junit: + - reports/phpunit.xml + - reports/phpcs.xml + - reports/psalm.xml diff --git a/README.md b/README.md index 73f076b8efcf957453241e6e00c2e8fac19eb02f..bd639fed75a92f66c327e07708217935b0d0f373 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,63 @@ -# cors-middleware +# CORS Middleware +A simple [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware for handling CORS. + +## Installation + +Install using Composer: + +```bash +composer require glance-project/cors-middleware +``` + +## Usage + +This middleware can be used with any framework compatible with PSR-7 and PSR-15. +On the following examples, Slim will be used. + +### Basic usage + +On most of the cases, the middleware can be used out of the box. + +```php +<?php + +use Glance\CorsMiddleware\CorsMiddleware; + +$app = new \Slim\App(); + +$corsMiddleware = CorsMiddleware::create(); + +$app->add($corsMiddleware); +``` + +It will add the following headers to your response: + +``` +Access-Control-Allow-Origin: * +Access-Control-Allow-Headers: Content-Type, Authorization +Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH +``` + +If the request has the method `OPTIONS` and empty response will be returned. + +### Custom origins + +```php +$corsMiddleware = CorsMiddleware::create() + ->withAllowedOrigins(["localhost"]); +``` + +### Custom headers + +```php +$corsMiddleware = CorsMiddleware::create() + ->withAllowedHeaders(["Content-Type", "Api-Key"]); +``` + +### Custom methods + +```php +$corsMiddleware = CorsMiddleware::create() + ->withAllowedMethods(["GET", "POST"]); +``` \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..5e2b7b626e59e45604cae03d984ca74e8fcf148c --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "glance-project/cors-middleware", + "description": "A simple PSR middleware to handle CORS", + "type": "library", + "license": "proprietary", + "config": { + "platform-check": false, + "sort-packages": true + }, + "authors": [ + { + "name": "Mario Gunter Simao", + "email": "mario.simao@cern.ch" + } + ], + "scripts": { + "test": [ + "@test:standard", + "@test:types", + "@test:unit" + ], + "test:ci": [ + "@test:ci:unit", + "@test:ci:standard", + "@test:types" + ], + "test:types": "psalm --show-info=true --no-diff", + "test:standard": "phpcs src/ tests/ --standard=psr12", + "test:unit": "phpunit tests --configuration phpunit.xml", + "test:ci:standard": "phpcs src/ tests/ --standard=psr12 --report=junit > reports/phpcs.xml", + "test:ci:unit": "phpunit tests --configuration phpunit.xml --coverage-text --colors=never --log-junit reports/phpunit.xml" + }, + "autoload": { + "psr-4": { + "GlanceProject\\CorsMiddleware\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "GlanceProject\\CorsMiddleware\\Tests\\": "tests/" + } + }, + "require": { + "php": "^7.2", + "psr/http-server-middleware": "^1.0", + "psr/http-factory": "^1.0", + "php-http/discovery": "^1.14" + }, + "require-dev": { + "dq5studios/psalm-junit": "^2.0", + "nyholm/psr7": "^1.5", + "phpunit/phpunit": "^8.5", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.1" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000000000000000000000000000000000000..b609246702cf96c84867bfc0d2066fecf3d45f79 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<phpunit + colors="true" +> + + <testsuites> + <testsuite name="Unit Tests"> + <directory>tests/Unit</directory> + </testsuite> + </testsuites> + + <filter> + <whitelist> + <directory suffix=".php">src/</directory> + </whitelist> + </filter> + +</phpunit> diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000000000000000000000000000000000000..540eb7d7b06671626b545529488eb73b6d6a1136 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,20 @@ +<?xml version="1.0"?> +<psalm + errorLevel="1" + resolveFromConfigFile="true" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xmlns="https://getpsalm.org/schema/config" + xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" +> + <projectFiles> + <directory name="src"/> + <ignoreFiles> + <directory name="vendor"/> + </ignoreFiles> + </projectFiles> + <plugins> + <pluginClass class="DQ5Studios\PsalmJunit\Plugin"> + <filepath>reports/psalm.xml</filepath> + </pluginClass> + </plugins> +</psalm> diff --git a/src/CorsMiddleware.php b/src/CorsMiddleware.php new file mode 100644 index 0000000000000000000000000000000000000000..b5a0f1bb2e1c5b0e5ebd223548046f95fb4285b3 --- /dev/null +++ b/src/CorsMiddleware.php @@ -0,0 +1,146 @@ +<?php + +namespace GlanceProject\CorsMiddleware; + +use Http\Discovery\Psr17FactoryDiscovery; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +class CorsMiddleware implements MiddlewareInterface +{ + private $responseFactory; + private $allowedOrigins; + private $allowedHeaders; + private $allowedMethods; + private $bypassMethods; + + /** + * @param ResponseFactoryInterface $responseFactory + * @param string[] $allowedOrigins + * @param string[] $allowedHeaders + * @param string[] $allowedMethods + * @param string[] $bypassMethods + */ + private function __construct( + ResponseFactoryInterface $responseFactory, + array $allowedOrigins, + array $allowedHeaders, + array $allowedMethods, + array $bypassMethods + ) { + $this->responseFactory = $responseFactory; + $this->allowedOrigins = $allowedOrigins; + $this->allowedHeaders = $allowedHeaders; + $this->allowedMethods = $allowedMethods; + $this->bypassMethods = $bypassMethods; + } + + public static function create(): self + { + return new self( + Psr17FactoryDiscovery::findResponseFactory(), + ["*"], + ["Content-Type", "Authorization"], + ["GET", "POST", "PUT", "DELETE", "PATCH"], + ["OPTIONS"] + ); + } + + /** + * Replace allowed origins + * + * Default value: "*" + * + * @param string[] $allowedOrigins + * + * @return self + */ + public function withAllowedOrigins(array $allowedOrigins): self + { + return new self( + $this->responseFactory, + $allowedOrigins, + $this->allowedHeaders, + $this->allowedMethods, + $this->bypassMethods + ); + } + + /** + * Replace allowed headers + * + * Default value: "Content-Type, Authorization" + * + * @param string[] $allowedHeaders + * + * @return self + */ + public function withAllowedHeaders(array $allowedHeaders): self + { + return new self( + $this->responseFactory, + $this->allowedOrigins, + $allowedHeaders, + $this->allowedMethods, + $this->bypassMethods + ); + } + + /** + * Replace allowed methods + * + * Default value: "GET, POST, PUT, DELTE, PATCH" + * + * @param string[] $allowedMethods + * + * @return self + */ + public function withAllowedMethods(array $allowedMethods): self + { + return new self( + $this->responseFactory, + $this->allowedOrigins, + $this->allowedHeaders, + $allowedMethods, + $this->bypassMethods + ); + } + + /** + * Replace bypass methods. + * + * Default value: "OPTIONS" + * + * @param string[] $bypassMethods + * + * @return self + */ + public function withBypassMethods(array $bypassMethods): self + { + return new self( + $this->responseFactory, + $this->allowedOrigins, + $this->allowedHeaders, + $this->allowedMethods, + $bypassMethods + ); + } + + public function process(Request $request, RequestHandlerInterface $handler): Response + { + $response = $this->responseFactory->createResponse(); + + $bypass = in_array($request->getMethod(), $this->bypassMethods); + if (!$bypass) { + $response = $handler->handle($request); + } + + return $response + ->withHeader("Access-Control-Allow-Origin", implode(", ", $this->allowedOrigins)) + ->withHeader("Access-Control-Allow-Headers", implode(", ", $this->allowedHeaders)) + ->withHeader("Access-Control-Allow-Methods", implode(", ", $this->allowedMethods)); + } +} diff --git a/tests/Unit/CorsMiddlewareTest.php b/tests/Unit/CorsMiddlewareTest.php new file mode 100644 index 0000000000000000000000000000000000000000..98ebd84e04694f60ee80a05b8c420b329879114e --- /dev/null +++ b/tests/Unit/CorsMiddlewareTest.php @@ -0,0 +1,123 @@ +<?php + +namespace GlanceProject\CorsMiddleware\Tests\Unit; + +use GlanceProject\CorsMiddleware\CorsMiddleware; +use Nyholm\Psr7\Response; +use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework\TestCase; +use Psr\Http\Server\RequestHandlerInterface; + +class CorsMiddlewareTest extends TestCase +{ + public function testMiddleware(): void + { + $request = new ServerRequest("GET", "http://glance.cern.ch/api/members"); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method("handle")->willReturn(new Response()); + + $middleware = CorsMiddleware::create(); + + $response = $middleware->process($request, $handler); + + $allowedOrigins = $response->getHeader("Access-Control-Allow-Origin"); + $allowedHeaders = $response->getHeader("Access-Control-Allow-Headers"); + $allowedMethods = $response->getHeader("Access-Control-Allow-Methods"); + + $this->assertSame([ "*" ], $allowedOrigins); + $this->assertSame([ "Content-Type, Authorization" ], $allowedHeaders); + $this->assertSame([ "GET, POST, PUT, DELETE, PATCH" ], $allowedMethods); + } + + public function testOptionsRequest(): void + { + $request = new ServerRequest("OPTIONS", "http://glance.cern.ch/api/members"); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method("handle")->willReturn(new Response(400)); + + $middleware = CorsMiddleware::create(); + + $response = $middleware->process($request, $handler); + + $allowedOrigins = $response->getHeader("Access-Control-Allow-Origin"); + $allowedHeaders = $response->getHeader("Access-Control-Allow-Headers"); + $allowedMethods = $response->getHeader("Access-Control-Allow-Methods"); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([ "*" ], $allowedOrigins); + $this->assertSame([ "Content-Type, Authorization" ], $allowedHeaders); + $this->assertSame([ "GET, POST, PUT, DELETE, PATCH" ], $allowedMethods); + } + + public function testCustomAllowedOrigins(): void + { + $request = new ServerRequest("GET", "http://glance.cern.ch/api/members"); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method("handle")->willReturn(new Response(200)); + + $customOrigins = [ "http://localhost:8080", "http://glance.cern.ch/client" ]; + $middleware = CorsMiddleware::create() + ->withAllowedOrigins($customOrigins); + + $response = $middleware->process($request, $handler); + + $allowedOrigins = $response->getHeader("Access-Control-Allow-Origin"); + + $this->assertSame([ "http://localhost:8080, http://glance.cern.ch/client" ], $allowedOrigins); + } + + public function testCustomAllowedHeaders(): void + { + $request = new ServerRequest("GET", "http://glance.cern.ch/api/members"); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method("handle")->willReturn(new Response(200)); + + $customHeaders = [ "Content-Type", "Api-Key" ]; + $middleware = CorsMiddleware::create() + ->withAllowedHeaders($customHeaders); + + $response = $middleware->process($request, $handler); + + $allowedHeaders = $response->getHeader("Access-Control-Allow-Headers"); + + $this->assertSame([ "Content-Type, Api-Key" ], $allowedHeaders); + } + + public function testCustomAllowedMethods(): void + { + $request = new ServerRequest("GET", "http://glance.cern.ch/api/members"); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method("handle")->willReturn(new Response(200)); + + $customMethods = [ "GET", "POST" ]; + $middleware = CorsMiddleware::create() + ->withAllowedMethods($customMethods); + + $response = $middleware->process($request, $handler); + + $allowedMethods = $response->getHeader("Access-Control-Allow-Methods"); + + $this->assertSame([ "GET, POST" ], $allowedMethods); + } + + public function testCustomBypassMethods(): void + { + $request = new ServerRequest("MYOPTIONS", "http://glance.cern.ch/api/members"); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method("handle")->willReturn(new Response(400)); + + $customMethods = [ "MYOPTIONS" ]; + $middleware = CorsMiddleware::create() + ->withBypassMethods($customMethods); + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + } +}