From 4b374d90fa35b2ce40f3f046b710765b047591df Mon Sep 17 00:00:00 2001 From: AI Date: Wed, 7 Jan 2026 08:06:41 +0000 Subject: [PATCH 1/3] Add PHP framework for Vercel with routing - Router with nette/routing support (GET, POST, PUT, PATCH, DELETE, OPTIONS, ANY) - Request wrapper with body parsing and parameter access - Response helpers (json, html, text, redirect, error responses) - Application entry point with error handling --- composer.json | 55 ++++++- src/Application.php | 158 ++++++++++++++++++++ src/Request.php | 249 +++++++++++++++++++++++++++++++ src/Response.php | 141 +++++++++++++++++ src/Router.php | 158 ++++++++++++++++++++ tests/Cases/ApplicationTest.phpt | 140 +++++++++++++++++ tests/Cases/RequestTest.phpt | 123 +++++++++++++++ tests/Cases/ResponseTest.phpt | 161 ++++++++++++++++++++ tests/Cases/RouterTest.phpt | 241 ++++++++++++++++++++++++++++++ tests/bootstrap.php | 10 ++ 10 files changed, 1433 insertions(+), 3 deletions(-) create mode 100644 src/Application.php create mode 100644 src/Request.php create mode 100644 src/Response.php create mode 100644 src/Router.php create mode 100644 tests/Cases/ApplicationTest.phpt create mode 100644 tests/Cases/RequestTest.phpt create mode 100644 tests/Cases/ResponseTest.phpt create mode 100644 tests/Cases/RouterTest.phpt create mode 100644 tests/bootstrap.php diff --git a/composer.json b/composer.json index 04cb0d8..2f1b3ca 100644 --- a/composer.json +++ b/composer.json @@ -1,4 +1,53 @@ { - "name": "juicyfx/now", - "description": "ZEIT Now PHP client" -} \ No newline at end of file + "name": "contributte/vercel", + "description": "Simple PHP framework for Vercel with routing based on nette/routing", + "license": "MIT", + "keywords": [ + "vercel", + "php", + "framework", + "routing", + "serverless", + "nette" + ], + "authors": [ + { + "name": "Milan Felix Sulc", + "homepage": "https://f3l1x.io" + } + ], + "require": { + "php": ">=8.2", + "nette/http": "^3.3.0", + "nette/routing": "^3.1.0" + }, + "require-dev": { + "contributte/phpstan": "~0.1.0", + "contributte/qa": "~0.4.0", + "contributte/tester": "~0.3.0", + "nette/tester": "^2.5.0" + }, + "autoload": { + "psr-4": { + "Contributte\\Vercel\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-master": "0.1.x-dev" + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + } +} diff --git a/src/Application.php b/src/Application.php new file mode 100644 index 0000000..fed585a --- /dev/null +++ b/src/Application.php @@ -0,0 +1,158 @@ + */ + private array $middlewares = []; + + /** @var callable|null */ + private $notFoundHandler = null; + + /** @var callable|null */ + private $errorHandler = null; + + public function __construct(?Router $router = null) + { + $this->router = $router ?? new Router(); + } + + public function getRouter(): Router + { + return $this->router; + } + + /** + * Add middleware to the application. + */ + public function use(Middleware|callable $middleware): self + { + $this->middlewares[] = $middleware; + + return $this; + } + + public function onNotFound(callable $handler): self + { + $this->notFoundHandler = $handler; + + return $this; + } + + public function onError(callable $handler): self + { + $this->errorHandler = $handler; + + return $this; + } + + public function run(?Request $request = null): void + { + $request ??= Request::fromGlobals(); + + try { + $response = $this->processMiddlewares($request); + $this->sendResponse($response); + } catch (Throwable $e) { + $this->handleError($e, $request); + } + } + + public function handle(Request $request): mixed + { + return $this->processMiddlewares($request); + } + + private function routeHandler(Request $request): mixed + { + $match = $this->router->match($request); + + if ($match === null) { + return $this->handleNotFound($request); + } + + $handler = $match['handler']; + $params = $match['params']; + + // Add route params to request + $request = $request->withParams($params); + + return $handler($request); + } + + private function processMiddlewares(Request $request): mixed + { + $middlewares = $this->middlewares; + $handler = fn (Request $req): mixed => $this->routeHandler($req); + + // Build middleware chain from end to start + while ($middleware = array_pop($middlewares)) { + $next = $handler; + $handler = fn (Request $req): mixed => $middleware($req, $next); + } + + return $handler($request); + } + + private function handleNotFound(Request $request): mixed + { + if ($this->notFoundHandler !== null) { + return ($this->notFoundHandler)($request); + } + + http_response_code(404); + + return Response::notFound(sprintf('Endpoint not found: %s', $request->getPath())); + } + + private function handleError(Throwable $e, Request $request): void + { + if ($this->errorHandler !== null) { + $response = ($this->errorHandler)($e, $request); + $this->sendResponse($response); + + return; + } + + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'error' => 'Internal Server Error', + 'message' => $e->getMessage(), + ], JSON_PRETTY_PRINT); + } + + private function sendResponse(mixed $response): void + { + if ($response instanceof Response) { + $response->send(); + + return; + } + + if (is_array($response) || is_object($response)) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return; + } + + if (is_string($response)) { + echo $response; + + return; + } + + if (is_int($response) || is_float($response)) { + echo (string) $response; + } + } + +} diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..dfc9acc --- /dev/null +++ b/src/Request.php @@ -0,0 +1,249 @@ + */ + private array $params; + + /** @var array */ + private array $query; + + /** @var array */ + private array $body; + + /** @var array */ + private array $headers; + + private string $rawBody; + + /** + * @param array $params + * @param array $query + * @param array $body + * @param array $headers + */ + public function __construct( + string $method = 'GET', + string $uri = '/', + array $params = [], + array $query = [], + array $body = [], + array $headers = [], + string $rawBody = '' + ) + { + $this->method = strtoupper($method); + $this->uri = $uri; + $this->params = $params; + $this->query = $query; + $this->body = $body; + $this->headers = $headers; + $this->rawBody = $rawBody; + } + + public static function fromGlobals(): self + { + // phpcs:disable SlevomatCodingStandard.Variables.DisallowSuperGlobalVariable.DisallowedSuperGlobalVariable + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + + $query = []; + $queryString = parse_url($uri, PHP_URL_QUERY); + if ($queryString !== null && $queryString !== false) { + parse_str($queryString, $query); + } + + $headers = []; + foreach ($_SERVER as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $name = str_replace('_', '-', substr($key, 5)); + $headers[$name] = $value; + } + } + + $rawBody = file_get_contents('php://input'); + $rawBody = $rawBody !== false ? $rawBody : ''; + $body = self::parseBody($rawBody, $_SERVER['CONTENT_TYPE'] ?? ''); + // phpcs:enable SlevomatCodingStandard.Variables.DisallowSuperGlobalVariable.DisallowedSuperGlobalVariable + + /** @phpstan-var array $query */ + return new self($method, $uri, [], $query, $body, $headers, $rawBody); + } + + /** + * @param array $params + */ + public function withParams(array $params): self + { + $clone = clone $this; + $clone->params = array_merge($clone->params, $params); + + return $clone; + } + + public function getMethod(): string + { + return $this->method; + } + + public function getUri(): string + { + return $this->uri; + } + + public function getPath(): string + { + $path = parse_url($this->uri, PHP_URL_PATH); + + return $path !== false && $path !== null ? $path : '/'; + } + + /** + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + public function getParam(string $name, mixed $default = null): mixed + { + return $this->params[$name] ?? $default; + } + + /** + * @return array + */ + public function getQuery(): array + { + return $this->query; + } + + public function getQueryParam(string $name, mixed $default = null): mixed + { + return $this->query[$name] ?? $default; + } + + /** + * @return array + */ + public function getBody(): array + { + return $this->body; + } + + public function getBodyParam(string $name, mixed $default = null): mixed + { + return $this->body[$name] ?? $default; + } + + public function getRawBody(): string + { + return $this->rawBody; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + public function getHeader(string $name): ?string + { + $name = strtoupper(str_replace('-', '_', $name)); + + foreach ($this->headers as $key => $value) { + if (strtoupper(str_replace('-', '_', $key)) === $name) { + return $value; + } + } + + return null; + } + + public function isMethod(string $method): bool + { + return $this->method === strtoupper($method); + } + + public function isGet(): bool + { + return $this->isMethod('GET'); + } + + public function isPost(): bool + { + return $this->isMethod('POST'); + } + + public function isPut(): bool + { + return $this->isMethod('PUT'); + } + + public function isPatch(): bool + { + return $this->isMethod('PATCH'); + } + + public function isDelete(): bool + { + return $this->isMethod('DELETE'); + } + + public function isAjax(): bool + { + return $this->getHeader('X-Requested-With') === 'XMLHttpRequest'; + } + + public function toNetteHttpRequest(): NetteHttpRequest + { + $path = $this->getPath(); + $queryString = http_build_query($this->query); + $fullUrl = 'http://localhost' . $path . ($queryString !== '' ? '?' . $queryString : ''); + $url = new UrlScript($fullUrl); + + return new NetteHttpRequest($url); + } + + /** + * @return array + */ + private static function parseBody(string $rawBody, string $contentType): array + { + if (str_contains($contentType, 'application/json')) { + if ($rawBody === '') { + return []; + } + + $data = json_decode($rawBody, true); + + return is_array($data) ? $data : []; + } + + if (str_contains($contentType, 'application/x-www-form-urlencoded')) { + // phpcs:ignore SlevomatCodingStandard.Variables.DisallowSuperGlobalVariable.DisallowedSuperGlobalVariable + return $_POST; + } + + if (str_contains($contentType, 'multipart/form-data')) { + // phpcs:ignore SlevomatCodingStandard.Variables.DisallowSuperGlobalVariable.DisallowedSuperGlobalVariable + return $_POST; + } + + return []; + } + +} diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..c259112 --- /dev/null +++ b/src/Response.php @@ -0,0 +1,141 @@ + */ + private array $headers = []; + + private string $body = ''; + + public static function json(mixed $data, int $status = 200): self + { + $response = new self(); + $response->statusCode = $status; + $response->headers['Content-Type'] = 'application/json; charset=utf-8'; + $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $response->body = $encoded !== false ? $encoded : ''; + + return $response; + } + + public static function html(string $html, int $status = 200): self + { + $response = new self(); + $response->statusCode = $status; + $response->headers['Content-Type'] = 'text/html; charset=utf-8'; + $response->body = $html; + + return $response; + } + + public static function text(string $text, int $status = 200): self + { + $response = new self(); + $response->statusCode = $status; + $response->headers['Content-Type'] = 'text/plain; charset=utf-8'; + $response->body = $text; + + return $response; + } + + public static function redirect(string $url, int $status = 302): self + { + $response = new self(); + $response->statusCode = $status; + $response->headers['Location'] = $url; + + return $response; + } + + public static function notFound(string $message = 'Not Found'): self + { + return self::json(['error' => $message], 404); + } + + public static function error(string $message = 'Internal Server Error', int $status = 500): self + { + return self::json(['error' => $message], $status); + } + + public static function badRequest(string $message = 'Bad Request'): self + { + return self::json(['error' => $message], 400); + } + + public static function unauthorized(string $message = 'Unauthorized'): self + { + return self::json(['error' => $message], 401); + } + + public static function forbidden(string $message = 'Forbidden'): self + { + return self::json(['error' => $message], 403); + } + + public static function noContent(): self + { + $response = new self(); + $response->statusCode = 204; + + return $response; + } + + public function withStatus(int $status): self + { + $clone = clone $this; + $clone->statusCode = $status; + + return $clone; + } + + public function withHeader(string $name, string $value): self + { + $clone = clone $this; + $clone->headers[$name] = $value; + + return $clone; + } + + public function withBody(string $body): self + { + $clone = clone $this; + $clone->body = $body; + + return $clone; + } + + public function send(): void + { + http_response_code($this->statusCode); + + foreach ($this->headers as $name => $value) { + header(sprintf('%s: %s', $name, $value)); + } + + echo $this->body; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + public function getBody(): string + { + return $this->body; + } + +} diff --git a/src/Router.php b/src/Router.php new file mode 100644 index 0000000..80c27d8 --- /dev/null +++ b/src/Router.php @@ -0,0 +1,158 @@ + */ + private array $routes = []; + + public function __construct() + { + $this->routeList = new RouteList(); + } + + public function getRouteList(): RouteList + { + return $this->routeList; + } + + /** + * @param array $metadata + */ + public function get(string $mask, callable $handler, array $metadata = []): self + { + return $this->addRoute('GET', $mask, $handler, $metadata); + } + + /** + * @param array $metadata + */ + public function post(string $mask, callable $handler, array $metadata = []): self + { + return $this->addRoute('POST', $mask, $handler, $metadata); + } + + /** + * @param array $metadata + */ + public function put(string $mask, callable $handler, array $metadata = []): self + { + return $this->addRoute('PUT', $mask, $handler, $metadata); + } + + /** + * @param array $metadata + */ + public function patch(string $mask, callable $handler, array $metadata = []): self + { + return $this->addRoute('PATCH', $mask, $handler, $metadata); + } + + /** + * @param array $metadata + */ + public function delete(string $mask, callable $handler, array $metadata = []): self + { + return $this->addRoute('DELETE', $mask, $handler, $metadata); + } + + /** + * @param array $metadata + */ + public function options(string $mask, callable $handler, array $metadata = []): self + { + return $this->addRoute('OPTIONS', $mask, $handler, $metadata); + } + + /** + * @param array $metadata + */ + public function any(string $mask, callable $handler, array $metadata = []): self + { + $routeId = $this->generateRouteId('ANY', $mask); + + $this->routes[$routeId] = [ + 'handler' => $handler, + 'method' => null, // null means any method + ]; + + $this->routeList->addRoute($mask, array_merge($metadata, [ + '_handler' => $routeId, + ])); + + return $this; + } + + /** + * @param array $metadata + */ + public function addRoute(string $method, string $mask, callable $handler, array $metadata = []): self + { + $routeId = $this->generateRouteId($method, $mask); + + $this->routes[$routeId] = [ + 'handler' => $handler, + 'method' => $method, + ]; + + $this->routeList->addRoute($mask, array_merge($metadata, [ + '_handler' => $routeId, + ])); + + return $this; + } + + /** + * Match request against routes + * + * @return array{handler: callable, params: array}|null + */ + public function match(Request $request): ?array + { + $httpRequest = $request->toNetteHttpRequest(); + $requestMethod = $request->getMethod(); + + // Try to find a matching route with correct method + foreach ($this->routeList->getRouters() as $route) { + $params = $route->match($httpRequest); + if ($params === null) { + continue; + } + + $handlerId = $params['_handler'] ?? null; + if ($handlerId === null || !isset($this->routes[$handlerId])) { + continue; + } + + $routeData = $this->routes[$handlerId]; + $routeMethod = $routeData['method']; + + // Check HTTP method (null means any method allowed) + if ($routeMethod !== null && $routeMethod !== $requestMethod) { + continue; + } + + // Remove internal params + unset($params['_handler']); + + return [ + 'handler' => $routeData['handler'], + 'params' => $params, + ]; + } + + return null; + } + + private function generateRouteId(string $method, string $mask): string + { + return $method . ':' . $mask . ':' . count($this->routes); + } + +} diff --git a/tests/Cases/ApplicationTest.phpt b/tests/Cases/ApplicationTest.phpt new file mode 100644 index 0000000..009722a --- /dev/null +++ b/tests/Cases/ApplicationTest.phpt @@ -0,0 +1,140 @@ +getRouter()); +}); + +// Create with custom router +Toolkit::test(function (): void { + $router = new Router(); + $app = new Application($router); + + Assert::same($router, $app->getRouter()); +}); + +// Handle matching route +Toolkit::test(function (): void { + $app = new Application(); + $app->getRouter()->get('/api/hello', fn (Request $request): array => ['message' => 'Hello']); + + $request = new Request('GET', '/api/hello'); + $result = $app->handle($request); + + Assert::same(['message' => 'Hello'], $result); +}); + +// Handle route with params +Toolkit::test(function (): void { + $app = new Application(); + $app->getRouter()->get('/api/users/', fn (Request $request): array => ['id' => $request->getParam('id')]); + + $request = new Request('GET', '/api/users/42'); + $result = $app->handle($request); + + Assert::same(['id' => '42'], $result); +}); + +// Handle not found +Toolkit::test(function (): void { + $app = new Application(); + $app->getRouter()->get('/api/hello', fn (Request $request): array => ['message' => 'Hello']); + + $request = new Request('GET', '/api/goodbye'); + $result = $app->handle($request); + + Assert::type(Response::class, $result); + Assert::same(404, $result->getStatusCode()); +}); + +// Custom not found handler +Toolkit::test(function (): void { + $app = new Application(); + $app->onNotFound(fn (Request $request): array => ['error' => 'Custom not found', 'path' => $request->getPath()]); + + $request = new Request('GET', '/unknown'); + $result = $app->handle($request); + + Assert::same(['error' => 'Custom not found', 'path' => '/unknown'], $result); +}); + +// Response object +Toolkit::test(function (): void { + $app = new Application(); + $app->getRouter()->get('/api/json', fn (Request $request): Response => Response::json(['success' => true], 201)); + + $request = new Request('GET', '/api/json'); + $result = $app->handle($request); + + Assert::type(Response::class, $result); + Assert::same(201, $result->getStatusCode()); +}); + +// Use middleware returns application +Toolkit::test(function (): void { + $app = new Application(); + $result = $app->use(Middlewares::cors()); + + Assert::same($app, $result); +}); + +// On not found returns application +Toolkit::test(function (): void { + $app = new Application(); + $result = $app->onNotFound(function (): void { + }); + + Assert::same($app, $result); +}); + +// On error returns application +Toolkit::test(function (): void { + $app = new Application(); + $result = $app->onError(function (): void { + }); + + Assert::same($app, $result); +}); + +// Middleware is called +Toolkit::test(function (): void { + $app = new Application(); + $tracker = new \stdClass(); + $tracker->called = false; + + $app->use(function (Request $request, callable $next) use ($tracker): mixed { + $tracker->called = true; + + return $next($request); + }); + + $app->getRouter()->get('/api/test', fn (Request $request): array => ['test' => true]); + + $request = new Request('GET', '/api/test'); + $result = $app->handle($request); + + Assert::true($tracker->called); + Assert::same(['test' => true], $result); +}); + +// Middlewares factory creates CORS middleware +Toolkit::test(function (): void { + $middleware = Middlewares::cors(); + + Assert::type(Middleware::class, $middleware); +}); diff --git a/tests/Cases/RequestTest.phpt b/tests/Cases/RequestTest.phpt new file mode 100644 index 0000000..12986a0 --- /dev/null +++ b/tests/Cases/RequestTest.phpt @@ -0,0 +1,123 @@ +getMethod()); + Assert::same('/', $request->getUri()); + Assert::same('/', $request->getPath()); + Assert::same([], $request->getParams()); + Assert::same([], $request->getQuery()); + Assert::same([], $request->getBody()); + Assert::same([], $request->getHeaders()); + Assert::same('', $request->getRawBody()); +}); + +// Constructor with values +Toolkit::test(function (): void { + $request = new Request( + method: 'POST', + uri: '/api/users?page=1', + params: ['id' => '123'], + query: ['page' => '1'], + body: ['name' => 'John'], + headers: ['Content-Type' => 'application/json'], + rawBody: '{"name":"John"}' + ); + + Assert::same('POST', $request->getMethod()); + Assert::same('/api/users?page=1', $request->getUri()); + Assert::same('/api/users', $request->getPath()); + Assert::same(['id' => '123'], $request->getParams()); + Assert::same('123', $request->getParam('id')); + Assert::null($request->getParam('nonexistent')); + Assert::same('default', $request->getParam('nonexistent', 'default')); + Assert::same(['page' => '1'], $request->getQuery()); + Assert::same('1', $request->getQueryParam('page')); + Assert::same(['name' => 'John'], $request->getBody()); + Assert::same('John', $request->getBodyParam('name')); + Assert::same(['Content-Type' => 'application/json'], $request->getHeaders()); + Assert::same('application/json', $request->getHeader('Content-Type')); + Assert::same('{"name":"John"}', $request->getRawBody()); +}); + +// Method uppercase +Toolkit::test(function (): void { + $request = new Request('post', '/'); + Assert::same('POST', $request->getMethod()); +}); + +// Is method helpers +Toolkit::test(function (): void { + Assert::true((new Request('GET', '/'))->isGet()); + Assert::true((new Request('POST', '/'))->isPost()); + Assert::true((new Request('PUT', '/'))->isPut()); + Assert::true((new Request('PATCH', '/'))->isPatch()); + Assert::true((new Request('DELETE', '/'))->isDelete()); + + Assert::false((new Request('POST', '/'))->isGet()); + Assert::false((new Request('GET', '/'))->isPost()); +}); + +// Is method +Toolkit::test(function (): void { + $request = new Request('GET', '/'); + + Assert::true($request->isMethod('GET')); + Assert::true($request->isMethod('get')); + Assert::false($request->isMethod('POST')); +}); + +// With params +Toolkit::test(function (): void { + $request = new Request('GET', '/', ['id' => '1']); + + $newRequest = $request->withParams(['name' => 'John']); + + // Original unchanged + Assert::same(['id' => '1'], $request->getParams()); + + // New request has merged params + Assert::same(['id' => '1', 'name' => 'John'], $newRequest->getParams()); +}); + +// Header case insensitive +Toolkit::test(function (): void { + $request = new Request( + headers: ['Content-Type' => 'application/json', 'X-Custom-Header' => 'value'] + ); + + Assert::same('application/json', $request->getHeader('Content-Type')); + Assert::same('application/json', $request->getHeader('content-type')); + Assert::same('application/json', $request->getHeader('CONTENT-TYPE')); + Assert::same('value', $request->getHeader('X-Custom-Header')); + Assert::same('value', $request->getHeader('x-custom-header')); + Assert::null($request->getHeader('NonExistent')); +}); + +// Is ajax +Toolkit::test(function (): void { + $request1 = new Request(headers: ['X-Requested-With' => 'XMLHttpRequest']); + Assert::true($request1->isAjax()); + + $request2 = new Request(); + Assert::false($request2->isAjax()); +}); + +// To Nette HTTP request +Toolkit::test(function (): void { + $request = new Request('GET', '/api/users', query: ['page' => '1']); + $netteRequest = $request->toNetteHttpRequest(); + + Assert::type('Nette\Http\Request', $netteRequest); + Assert::same('/api/users', $netteRequest->getUrl()->getPath()); +}); diff --git a/tests/Cases/ResponseTest.phpt b/tests/Cases/ResponseTest.phpt new file mode 100644 index 0000000..46a1f15 --- /dev/null +++ b/tests/Cases/ResponseTest.phpt @@ -0,0 +1,161 @@ + 'Hello']); + + Assert::same(200, $response->getStatusCode()); + Assert::same('application/json; charset=utf-8', $response->getHeaders()['Content-Type']); + Assert::contains('"message": "Hello"', $response->getBody()); +}); + +// JSON response with status +Toolkit::test(function (): void { + $response = Response::json(['created' => true], 201); + + Assert::same(201, $response->getStatusCode()); +}); + +// HTML response +Toolkit::test(function (): void { + $response = Response::html('

Hello

'); + + Assert::same(200, $response->getStatusCode()); + Assert::same('text/html; charset=utf-8', $response->getHeaders()['Content-Type']); + Assert::same('

Hello

', $response->getBody()); +}); + +// Text response +Toolkit::test(function (): void { + $response = Response::text('Hello World'); + + Assert::same(200, $response->getStatusCode()); + Assert::same('text/plain; charset=utf-8', $response->getHeaders()['Content-Type']); + Assert::same('Hello World', $response->getBody()); +}); + +// Redirect response +Toolkit::test(function (): void { + $response = Response::redirect('https://example.com'); + + Assert::same(302, $response->getStatusCode()); + Assert::same('https://example.com', $response->getHeaders()['Location']); +}); + +// Redirect with custom status +Toolkit::test(function (): void { + $response = Response::redirect('https://example.com', 301); + + Assert::same(301, $response->getStatusCode()); +}); + +// Not found response +Toolkit::test(function (): void { + $response = Response::notFound(); + + Assert::same(404, $response->getStatusCode()); + Assert::contains('"error": "Not Found"', $response->getBody()); +}); + +// Not found with message +Toolkit::test(function (): void { + $response = Response::notFound('User not found'); + + Assert::same(404, $response->getStatusCode()); + Assert::contains('"error": "User not found"', $response->getBody()); +}); + +// Error response +Toolkit::test(function (): void { + $response = Response::error(); + + Assert::same(500, $response->getStatusCode()); + Assert::contains('"error": "Internal Server Error"', $response->getBody()); +}); + +// Error with custom message +Toolkit::test(function (): void { + $response = Response::error('Database error', 503); + + Assert::same(503, $response->getStatusCode()); + Assert::contains('"error": "Database error"', $response->getBody()); +}); + +// Bad request response +Toolkit::test(function (): void { + $response = Response::badRequest(); + + Assert::same(400, $response->getStatusCode()); + Assert::contains('"error": "Bad Request"', $response->getBody()); +}); + +// Unauthorized response +Toolkit::test(function (): void { + $response = Response::unauthorized(); + + Assert::same(401, $response->getStatusCode()); + Assert::contains('"error": "Unauthorized"', $response->getBody()); +}); + +// Forbidden response +Toolkit::test(function (): void { + $response = Response::forbidden(); + + Assert::same(403, $response->getStatusCode()); + Assert::contains('"error": "Forbidden"', $response->getBody()); +}); + +// No content response +Toolkit::test(function (): void { + $response = Response::noContent(); + + Assert::same(204, $response->getStatusCode()); + Assert::same('', $response->getBody()); +}); + +// With status +Toolkit::test(function (): void { + $response = Response::json(['data' => 'test']); + $newResponse = $response->withStatus(201); + + Assert::same(200, $response->getStatusCode()); + Assert::same(201, $newResponse->getStatusCode()); +}); + +// With header +Toolkit::test(function (): void { + $response = Response::json(['data' => 'test']); + $newResponse = $response->withHeader('X-Custom', 'value'); + + Assert::false(isset($response->getHeaders()['X-Custom'])); + Assert::same('value', $newResponse->getHeaders()['X-Custom']); +}); + +// With body +Toolkit::test(function (): void { + $response = Response::text('original'); + $newResponse = $response->withBody('modified'); + + Assert::same('original', $response->getBody()); + Assert::same('modified', $newResponse->getBody()); +}); + +// Immutability +Toolkit::test(function (): void { + $response1 = Response::json(['a' => 1]); + $response2 = $response1->withStatus(201); + $response3 = $response2->withHeader('X-Test', 'value'); + + Assert::notSame($response1, $response2); + Assert::notSame($response2, $response3); + Assert::same(200, $response1->getStatusCode()); + Assert::same(201, $response2->getStatusCode()); +}); diff --git a/tests/Cases/RouterTest.phpt b/tests/Cases/RouterTest.phpt new file mode 100644 index 0000000..b85abf8 --- /dev/null +++ b/tests/Cases/RouterTest.phpt @@ -0,0 +1,241 @@ +called = false; + + $router->get('/api/hello', function (Request $request) use ($tracker): array { + $tracker->called = true; + + return ['message' => 'Hello']; + }); + + $request = new Request('GET', '/api/hello'); + $match = $router->match($request); + + Assert::notNull($match); + Assert::type('callable', $match['handler']); + Assert::same([], $match['params']); + + // Execute handler + $result = $match['handler']($request); + Assert::true($tracker->called); + Assert::same(['message' => 'Hello'], $result); +}); + +// Route with parameter +Toolkit::test(function (): void { + $router = new Router(); + + $router->get('/api/users/', fn (Request $request): array => ['id' => $request->getParam('id')]); + + $request = new Request('GET', '/api/users/123'); + $match = $router->match($request); + + Assert::notNull($match); + Assert::same('123', $match['params']['id']); +}); + +// Route with multiple parameters +Toolkit::test(function (): void { + $router = new Router(); + + $router->get('/api//', fn (Request $request): array => [ + 'module' => $request->getParam('module'), + 'action' => $request->getParam('action'), + ]); + + $request = new Request('GET', '/api/users/list'); + $match = $router->match($request); + + Assert::notNull($match); + Assert::same('users', $match['params']['module']); + Assert::same('list', $match['params']['action']); +}); + +// Route with optional parameter +Toolkit::test(function (): void { + $router = new Router(); + + $router->get('/api/users[/]', fn (Request $request): array => ['id' => $request->getParam('id')]); + + // With parameter + $request1 = new Request('GET', '/api/users/42'); + $match1 = $router->match($request1); + Assert::notNull($match1); + Assert::same('42', $match1['params']['id']); + + // Without parameter + $request2 = new Request('GET', '/api/users'); + $match2 = $router->match($request2); + Assert::notNull($match2); + Assert::null($match2['params']['id'] ?? null); +}); + +// Route with regex constraint +Toolkit::test(function (): void { + $router = new Router(); + + $router->get('/api/users/', fn (Request $request): array => ['id' => $request->getParam('id')]); + + // Valid numeric ID + $request1 = new Request('GET', '/api/users/123'); + $match1 = $router->match($request1); + Assert::notNull($match1); + + // Invalid non-numeric ID + $request2 = new Request('GET', '/api/users/abc'); + $match2 = $router->match($request2); + Assert::null($match2); +}); + +// POST route +Toolkit::test(function (): void { + $router = new Router(); + + $router->post('/api/users', fn (Request $request): array => ['created' => true]); + + // POST request should match + $request1 = new Request('POST', '/api/users'); + $match1 = $router->match($request1); + Assert::notNull($match1); + + // GET request should not match + $request2 = new Request('GET', '/api/users'); + $match2 = $router->match($request2); + Assert::null($match2); +}); + +// PUT route +Toolkit::test(function (): void { + $router = new Router(); + + $router->put('/api/users/', fn (Request $request): array => ['updated' => true]); + + $request = new Request('PUT', '/api/users/1'); + $match = $router->match($request); + + Assert::notNull($match); + Assert::same('1', $match['params']['id']); +}); + +// PATCH route +Toolkit::test(function (): void { + $router = new Router(); + + $router->patch('/api/users/', fn (Request $request): array => ['patched' => true]); + + $request = new Request('PATCH', '/api/users/1'); + $match = $router->match($request); + + Assert::notNull($match); +}); + +// DELETE route +Toolkit::test(function (): void { + $router = new Router(); + + $router->delete('/api/users/', fn (Request $request): array => ['deleted' => true]); + + $request = new Request('DELETE', '/api/users/1'); + $match = $router->match($request); + + Assert::notNull($match); +}); + +// Any route +Toolkit::test(function (): void { + $router = new Router(); + + $router->any('/api/any', fn (Request $request): array => ['method' => $request->getMethod()]); + + $methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + + foreach ($methods as $method) { + $request = new Request($method, '/api/any'); + $match = $router->match($request); + Assert::notNull($match, sprintf('Method %s should match', $method)); + } +}); + +// No match +Toolkit::test(function (): void { + $router = new Router(); + + $router->get('/api/hello', fn (Request $request): array => ['message' => 'Hello']); + + $request = new Request('GET', '/api/goodbye'); + $match = $router->match($request); + + Assert::null($match); +}); + +// Multiple routes +Toolkit::test(function (): void { + $router = new Router(); + + $router->get('/api/users', fn (Request $request): array => ['action' => 'list']); + + $router->get('/api/users/', fn (Request $request): array => ['action' => 'detail']); + + $router->post('/api/users', fn (Request $request): array => ['action' => 'create']); + + // List + $request1 = new Request('GET', '/api/users'); + $match1 = $router->match($request1); + Assert::notNull($match1); + $result1 = $match1['handler'](new Request()); + Assert::same('list', $result1['action']); + + // Detail + $request2 = new Request('GET', '/api/users/1'); + $match2 = $router->match($request2); + Assert::notNull($match2); + $result2 = $match2['handler'](new Request()); + Assert::same('detail', $result2['action']); + + // Create + $request3 = new Request('POST', '/api/users'); + $match3 = $router->match($request3); + Assert::notNull($match3); + $result3 = $match3['handler'](new Request()); + Assert::same('create', $result3['action']); +}); + +// Route with default value +Toolkit::test(function (): void { + $router = new Router(); + + $router->get('/api/page[/]', fn (Request $request): array => ['page' => $request->getParam('page')]); + + // With page + $request1 = new Request('GET', '/api/page/5'); + $match1 = $router->match($request1); + Assert::notNull($match1); + Assert::same('5', $match1['params']['page']); + + // Without page (default) + $request2 = new Request('GET', '/api/page'); + $match2 = $router->match($request2); + Assert::notNull($match2); + Assert::same('1', $match2['params']['page']); +}); + +// Get route list +Toolkit::test(function (): void { + $router = new Router(); + $routeList = $router->getRouteList(); + + Assert::type('Nette\Routing\RouteList', $routeList); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..86c92a0 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,10 @@ + Date: Wed, 7 Jan 2026 08:06:59 +0000 Subject: [PATCH 2/3] Add middleware support - Middleware interface for request/response processing - CorsMiddleware for CORS headers handling - Middlewares factory with cors() helper --- src/Middleware/CorsMiddleware.php | 87 +++++++++++++++++++ src/Middleware/Middleware.php | 19 ++++ src/Middlewares.php | 27 ++++++ .../Cases/Middleware/CorsMiddlewareTest.phpt | 77 ++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 src/Middleware/CorsMiddleware.php create mode 100644 src/Middleware/Middleware.php create mode 100644 src/Middlewares.php create mode 100644 tests/Cases/Middleware/CorsMiddlewareTest.phpt diff --git a/src/Middleware/CorsMiddleware.php b/src/Middleware/CorsMiddleware.php new file mode 100644 index 0000000..9dcfecf --- /dev/null +++ b/src/Middleware/CorsMiddleware.php @@ -0,0 +1,87 @@ +allowOrigin = $allowOrigin; + $this->allowMethods = $allowMethods; + $this->allowHeaders = $allowHeaders; + $this->maxAge = $maxAge; + } + + private function createPreflightResponse(): Response + { + return Response::noContent() + ->withHeader('Access-Control-Allow-Origin', $this->allowOrigin) + ->withHeader('Access-Control-Allow-Methods', $this->allowMethods) + ->withHeader('Access-Control-Allow-Headers', $this->allowHeaders) + ->withHeader('Access-Control-Max-Age', (string) $this->maxAge); + } + + private function addCorsHeaders(Response $response): Response + { + return $response + ->withHeader('Access-Control-Allow-Origin', $this->allowOrigin) + ->withHeader('Access-Control-Allow-Methods', $this->allowMethods) + ->withHeader('Access-Control-Allow-Headers', $this->allowHeaders) + ->withHeader('Access-Control-Max-Age', (string) $this->maxAge); + } + + private function sendCorsHeaders(): void + { + header('Access-Control-Allow-Origin: ' . $this->allowOrigin); + header('Access-Control-Allow-Methods: ' . $this->allowMethods); + header('Access-Control-Allow-Headers: ' . $this->allowHeaders); + header('Access-Control-Max-Age: ' . $this->maxAge); + } + + /** + * @return Response|array|string|null + */ + public function __invoke(Request $request, callable $next): Response|array|string|null + { + // Handle preflight request + if ($request->isMethod('OPTIONS')) { + return $this->createPreflightResponse(); + } + + // Process request + $response = $next($request); + + // Add CORS headers to response + if ($response instanceof Response) { + return $this->addCorsHeaders($response); + } + + // For non-Response returns, headers will be added by Application + $this->sendCorsHeaders(); + + if (is_array($response) || is_string($response) || $response === null) { + return $response; + } + + // Convert other types to array for JSON encoding + return ['data' => $response]; + } + +} diff --git a/src/Middleware/Middleware.php b/src/Middleware/Middleware.php new file mode 100644 index 0000000..bd9e51c --- /dev/null +++ b/src/Middleware/Middleware.php @@ -0,0 +1,19 @@ +|string|null + */ + public function __invoke(Request $request, callable $next): Response|array|string|null; + +} diff --git a/src/Middlewares.php b/src/Middlewares.php new file mode 100644 index 0000000..53ec9c7 --- /dev/null +++ b/src/Middlewares.php @@ -0,0 +1,27 @@ + ['test' => true]); + + Assert::type(Response::class, $response); + Assert::same(204, $response->getStatusCode()); + Assert::same('*', $response->getHeaders()['Access-Control-Allow-Origin']); +}); + +// Response has CORS headers +Toolkit::test(function (): void { + $middleware = new CorsMiddleware(); + $request = new Request('GET', '/api/test'); + + $response = $middleware($request, fn (Request $r): Response => Response::json(['test' => true])); + + Assert::type(Response::class, $response); + Assert::same('*', $response->getHeaders()['Access-Control-Allow-Origin']); + Assert::contains('GET', $response->getHeaders()['Access-Control-Allow-Methods']); +}); + +// Custom CORS configuration +Toolkit::test(function (): void { + $middleware = new CorsMiddleware( + allowOrigin: 'https://example.com', + allowMethods: 'GET, POST', + allowHeaders: 'Content-Type', + maxAge: 3600 + ); + + $request = new Request('OPTIONS', '/api/test'); + $response = $middleware($request, fn (Request $r): array => ['test' => true]); + + Assert::same('https://example.com', $response->getHeaders()['Access-Control-Allow-Origin']); + Assert::same('GET, POST', $response->getHeaders()['Access-Control-Allow-Methods']); + Assert::same('Content-Type', $response->getHeaders()['Access-Control-Allow-Headers']); + Assert::same('3600', $response->getHeaders()['Access-Control-Max-Age']); +}); + +// Passes to next middleware +Toolkit::test(function (): void { + $middleware = new CorsMiddleware(); + $request = new Request('GET', '/api/test'); + $tracker = new \stdClass(); + $tracker->called = false; + + $middleware($request, function (Request $r) use ($tracker): Response { + $tracker->called = true; + + return Response::json(['test' => true]); + }); + + Assert::true($tracker->called); +}); From 8353391ad0fef8b518138023602015d91409665c Mon Sep 17 00:00:00 2001 From: AI Date: Wed, 7 Jan 2026 08:07:19 +0000 Subject: [PATCH 3/3] Add CI workflows and documentation - GitHub Actions for tests, phpstan, codesniffer, coverage - PHPStan level 9 configuration - Code style ruleset - Documentation in .docs/README.md --- .docs/README.md | 109 ++++++++++++++++++++++++++++++ .editorconfig | 13 ++++ .gitattributes | 9 +++ .github/workflows/codesniffer.yml | 17 +++++ .github/workflows/coverage.yml | 17 +++++ .github/workflows/phpstan.yml | 17 +++++ .github/workflows/tests.yml | 42 ++++++++++++ .gitignore | 11 +++ Makefile | 34 ++++++++++ README.md | 41 +++++++++++ phpstan.neon | 16 +++++ ruleset.xml | 15 ++++ 12 files changed, 341 insertions(+) create mode 100644 .docs/README.md create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/codesniffer.yml create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/phpstan.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 phpstan.neon create mode 100644 ruleset.xml diff --git a/.docs/README.md b/.docs/README.md new file mode 100644 index 0000000..9c49819 --- /dev/null +++ b/.docs/README.md @@ -0,0 +1,109 @@ +# Documentation + +## Setup + +Create `api/index.php`: + +```php +use(Middlewares::cors()); + +$app->getRouter()->get('/api/hello', fn(Request $r) => ['message' => 'Hello']); + +$app->run(); +``` + +Create `vercel.json`: + +```json +{ + "functions": { + "api/*.php": { + "runtime": "vercel-php@0.7.2" + } + }, + "rewrites": [ + { "source": "/api/(.*)", "destination": "/api/index.php" } + ] +} +``` + +## Routing + +```php +$router = $app->getRouter(); + +$router->get('/api/users', fn(Request $r) => ['users' => []]); +$router->post('/api/users', fn(Request $r) => ['created' => true]); +$router->put('/api/users/', fn(Request $r) => ['updated' => true]); +$router->delete('/api/users/', fn(Request $r) => ['deleted' => true]); +$router->any('/api/any', fn(Request $r) => ['method' => $r->getMethod()]); + +// Parameters +$router->get('/api/users/', fn(Request $r) => ['id' => $r->getParam('id')]); +$router->get('/api/users/', fn(Request $r) => ['id' => (int) $r->getParam('id')]); +$router->get('/api/page[/]', fn(Request $r) => ['page' => $r->getParam('page')]); +``` + +## Request + +```php +$request->getMethod(); // GET, POST, ... +$request->getPath(); // /api/users +$request->getParam('id'); // Route parameter +$request->getQueryParam('page'); // Query string +$request->getBodyParam('name'); // JSON/form body +$request->getHeader('Authorization'); +``` + +## Response + +```php +use Contributte\Vercel\Response; + +Response::json(['data' => 'value']); +Response::json(['created' => true], 201); +Response::html('

Hello

'); +Response::text('Hello'); +Response::redirect('/other'); +Response::notFound(); +Response::badRequest(); +Response::error('Server error', 500); +``` + +## Middleware + +```php +use Contributte\Vercel\Middlewares; + +$app->use(Middlewares::cors()); +$app->use(Middlewares::cors( + allowOrigin: 'https://example.com', + allowMethods: 'GET, POST', + allowHeaders: 'Content-Type', + maxAge: 3600 +)); + +// Custom middleware +$app->use(function (Request $request, callable $next) { + // before + $response = $next($request); + // after + return $response; +}); +``` + +## Error Handling + +```php +$app->onNotFound(fn(Request $r) => Response::json(['error' => 'Not found'], 404)); +$app->onError(fn(Throwable $e, Request $r) => Response::error($e->getMessage())); +``` diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0b9b831 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +indent_size = 4 + +[*.{json,yaml,yml,md}] +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f343907 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +.docs export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore +Makefile export-ignore +phpstan.neon export-ignore +ruleset.xml export-ignore +tests export-ignore diff --git a/.github/workflows/codesniffer.yml b/.github/workflows/codesniffer.yml new file mode 100644 index 0000000..a2e440c --- /dev/null +++ b/.github/workflows/codesniffer.yml @@ -0,0 +1,17 @@ +name: Codesniffer + +on: + pull_request: + workflow_dispatch: + push: + branches: + - "**" + schedule: + - cron: "0 8 * * 1" + +jobs: + codesniffer: + name: Codesniffer + uses: contributte/.github/.github/workflows/codesniffer.yml@master + with: + php: 8.2 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..8bf541d --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,17 @@ +name: Coverage + +on: + pull_request: + workflow_dispatch: + push: + branches: + - "**" + schedule: + - cron: "0 9 * * 1" + +jobs: + coverage: + name: Nette Tester + uses: contributte/.github/.github/workflows/nette-tester-coverage-v2.yml@master + with: + php: 8.2 diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..cb3bb54 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,17 @@ +name: Phpstan + +on: + pull_request: + workflow_dispatch: + push: + branches: + - "**" + schedule: + - cron: "0 10 * * 1" + +jobs: + phpstan: + name: Phpstan + uses: contributte/.github/.github/workflows/phpstan.yml@master + with: + php: 8.2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..196bb6d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,42 @@ +name: Tests + +on: + pull_request: + workflow_dispatch: + push: + branches: + - "**" + schedule: + - cron: "0 10 * * 1" + +jobs: + test85: + name: Tests (PHP 8.5) + uses: contributte/.github/.github/workflows/nette-tester.yml@master + with: + php: 8.5 + + test84: + name: Tests (PHP 8.4) + uses: contributte/.github/.github/workflows/nette-tester.yml@master + with: + php: 8.4 + + test83: + name: Tests (PHP 8.3) + uses: contributte/.github/.github/workflows/nette-tester.yml@master + with: + php: 8.3 + + test82: + name: Tests (PHP 8.2) + uses: contributte/.github/.github/workflows/nette-tester.yml@master + with: + php: 8.2 + + testlowest: + name: Tests (PHP 8.2, lowest) + uses: contributte/.github/.github/workflows/nette-tester.yml@master + with: + php: 8.2 + composer: composer update --no-interaction --no-progress --prefer-dist --prefer-lowest --prefer-stable diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38eaabf --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.idea + +/vendor +/composer.lock + +/tests/tmp +/coverage.* +*.log +*.html +*.expected +*.actual diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1c2e2d6 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: install +install: + composer update + +.PHONY: qa +qa: phpstan cs + +.PHONY: cs +cs: +ifdef GITHUB_ACTION + vendor/bin/phpcs --standard=ruleset.xml --extensions="php,phpt" --encoding=utf-8 --colors -nsp -q --report=checkstyle src tests | cs2pr +else + vendor/bin/phpcs --standard=ruleset.xml --extensions="php,phpt" --encoding=utf-8 --colors -nsp src tests +endif + +.PHONY: csf +csf: + vendor/bin/phpcbf --standard=ruleset.xml --extensions="php,phpt" --encoding=utf-8 --colors -nsp src tests + +.PHONY: phpstan +phpstan: + vendor/bin/phpstan analyse -c phpstan.neon + +.PHONY: tests +tests: + vendor/bin/tester -s -p php --colors 1 -C tests/Cases + +.PHONY: coverage +coverage: +ifdef GITHUB_ACTION + vendor/bin/tester -s -p phpdbg --colors 1 -C --coverage ./coverage.xml --coverage-src ./src tests/Cases +else + vendor/bin/tester -s -p phpdbg --colors 1 -C --coverage ./coverage.html --coverage-src ./src tests/Cases +endif diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bdbef0 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Contributte / Vercel + +Simple PHP framework for Vercel serverless functions. + +[![Build Status](https://badgen.net/github/checks/contributte/vercel/master)](https://github.com/contributte/vercel/actions) +[![Coverage Status](https://badgen.net/coveralls/c/github/contributte/vercel)](https://coveralls.io/github/contributte/vercel) +[![Downloads](https://badgen.net/packagist/dt/contributte/vercel)](https://packagist.org/packages/contributte/vercel) +[![Latest version](https://badgen.net/packagist/v/contributte/vercel)](https://packagist.org/packages/contributte/vercel) +[![PHPStan](https://badgen.net/badge/PHPStan/level%209/green)](https://github.com/phpstan/phpstan) + +## Usage + +To install the latest version of `contributte/vercel` use [Composer](https://getcomposer.com). + +```bash +composer require contributte/vercel +``` + +## Documentation + +For details, check out https://contributte.org/packages/contributte/vercel + +## Versions + +| State | Version | Branch | Nette | PHP | +|-------|---------|----------|--------|---------| +| dev | `^0.1` | `master` | `3.2+` | `>=8.2` | + +## Development + +See [how to contribute](https://contributte.org/contributing.html) to this package. + +This package is currently maintaining by these authors. + + + + + +--- + +Consider to [support](https://github.com/sponsors/f3l1x) **contributte** development team. Also thank you for using this package. diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..c7c1e45 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,16 @@ +includes: + - vendor/contributte/phpstan/phpstan.neon + +parameters: + level: 9 + phpVersion: 80200 + + scanDirectories: + - src + + fileExtensions: + - php + + paths: + - src + - tests diff --git a/ruleset.xml b/ruleset.xml new file mode 100644 index 0000000..e5fa648 --- /dev/null +++ b/ruleset.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + /tests/tmp +