initial commit
This commit is contained in:
commit
1b65ee2462
95
context.php
Normal file
95
context.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Context holds the request, response, and shared state for a request
|
||||
*/
|
||||
class Context
|
||||
{
|
||||
public Request $request;
|
||||
public Response $response;
|
||||
public array $state = [];
|
||||
|
||||
/**
|
||||
* __construct creates a new Context with request and response
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->request = new Request();
|
||||
$this->response = new Response();
|
||||
}
|
||||
|
||||
/**
|
||||
* set stores a value in the context state
|
||||
*/
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
$this->state[$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* get retrieves a value from the context state
|
||||
*/
|
||||
public function get(string $key): mixed
|
||||
{
|
||||
return $this->state[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* json sends a JSON response
|
||||
*/
|
||||
public function json(mixed $data, int $status = 200): void
|
||||
{
|
||||
$this->response->json($data, $status)->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* text sends a plain text response
|
||||
*/
|
||||
public function text(string $text, int $status = 200): void
|
||||
{
|
||||
$this->response->text($text, $status)->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* html sends an HTML response
|
||||
*/
|
||||
public function html(string $html, int $status = 200): void
|
||||
{
|
||||
$this->response->html($html, $status)->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect sends a redirect response
|
||||
*/
|
||||
public function redirect(string $url, int $status = 302): void
|
||||
{
|
||||
$this->response->redirect($url, $status)->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* error sends an error response with appropriate content type
|
||||
*/
|
||||
public function error(int $status, string $message = ''): void
|
||||
{
|
||||
$messages = [
|
||||
400 => 'Bad Request',
|
||||
401 => 'Unauthorized',
|
||||
403 => 'Forbidden',
|
||||
404 => 'Not Found',
|
||||
405 => 'Method Not Allowed',
|
||||
500 => 'Internal Server Error',
|
||||
502 => 'Bad Gateway',
|
||||
503 => 'Service Unavailable'
|
||||
];
|
||||
|
||||
$message = $message ?: ($messages[$status] ?? 'Error');
|
||||
|
||||
$this->response->status($status);
|
||||
|
||||
if ($this->request->header('accept') && str_contains($this->request->header('accept'), 'application/json')) {
|
||||
$this->json(['error' => $message], $status);
|
||||
} else {
|
||||
$this->text($message, $status);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
methods.php
Normal file
39
methods.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* HTTPMethod represents an HTTP method
|
||||
*/
|
||||
enum HTTPMethod: string {
|
||||
case GET = 'GET';
|
||||
case POST = 'POST';
|
||||
case PUT = 'PUT';
|
||||
case DELETE = 'DELETE';
|
||||
case PATCH = 'PATCH';
|
||||
case OPTIONS = 'OPTIONS';
|
||||
case HEAD = 'HEAD';
|
||||
|
||||
/**
|
||||
* fromString converts a string to an HTTPMethod
|
||||
*/
|
||||
public static function fromString(string $method): self
|
||||
{
|
||||
return match(strtoupper($method)) {
|
||||
'GET' => self::GET,
|
||||
'POST' => self::POST,
|
||||
'PUT' => self::PUT,
|
||||
'DELETE' => self::DELETE,
|
||||
'PATCH' => self::PATCH,
|
||||
'OPTIONS' => self::OPTIONS,
|
||||
'HEAD' => self::HEAD,
|
||||
default => self::GET
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* toString returns the string representation of the method
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
105
request.php
Normal file
105
request.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Request represents an incoming HTTP request
|
||||
*/
|
||||
class Request
|
||||
{
|
||||
public string $method;
|
||||
public string $uri;
|
||||
public string $path;
|
||||
public string $query;
|
||||
public array $headers;
|
||||
public string $body;
|
||||
public array $params = [];
|
||||
public array $queryParams = [];
|
||||
public array $postData = [];
|
||||
public array $cookies;
|
||||
|
||||
/**
|
||||
* __construct creates a new Request from PHP globals
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$this->uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
|
||||
$urlParts = parse_url($this->uri);
|
||||
$this->path = $urlParts['path'] ?? '/';
|
||||
$this->query = $urlParts['query'] ?? '';
|
||||
|
||||
parse_str($this->query, $this->queryParams);
|
||||
|
||||
$this->headers = $this->parseHeaders();
|
||||
$this->body = file_get_contents('php://input') ?: '';
|
||||
$this->cookies = $_COOKIE ?? [];
|
||||
|
||||
if ($this->method === 'POST' && $this->contentType() === 'application/x-www-form-urlencoded') {
|
||||
parse_str($this->body, $this->postData);
|
||||
} elseif ($this->method === 'POST' && str_contains($this->contentType(), 'multipart/form-data')) {
|
||||
$this->postData = $_POST ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* parseHeaders extracts HTTP headers from $_SERVER
|
||||
*/
|
||||
private function parseHeaders(): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (str_starts_with($key, 'HTTP_')) {
|
||||
$header = str_replace('_', '-', substr($key, 5));
|
||||
$headers[strtolower($header)] = $value;
|
||||
}
|
||||
}
|
||||
if (isset($_SERVER['CONTENT_TYPE'])) {
|
||||
$headers['content-type'] = $_SERVER['CONTENT_TYPE'];
|
||||
}
|
||||
if (isset($_SERVER['CONTENT_LENGTH'])) {
|
||||
$headers['content-length'] = $_SERVER['CONTENT_LENGTH'];
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* header returns the value of the specified header
|
||||
*/
|
||||
public function header(string $name): ?string
|
||||
{
|
||||
return $this->headers[strtolower($name)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* contentType returns the content type without charset
|
||||
*/
|
||||
public function contentType(): string
|
||||
{
|
||||
$contentType = $this->header('content-type') ?? '';
|
||||
return explode(';', $contentType)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* json decodes the request body as JSON
|
||||
*/
|
||||
public function json(): mixed
|
||||
{
|
||||
return json_decode($this->body, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* cookie returns the value of the specified cookie
|
||||
*/
|
||||
public function cookie(string $name): ?string
|
||||
{
|
||||
return $this->cookies[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* param returns a parameter from route params, query params, or post data
|
||||
*/
|
||||
public function param(string $name): mixed
|
||||
{
|
||||
return $this->params[$name] ?? $this->queryParams[$name] ?? $this->postData[$name] ?? null;
|
||||
}
|
||||
}
|
||||
138
response.php
Normal file
138
response.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Response class represents an HTTP response
|
||||
*/
|
||||
class Response
|
||||
{
|
||||
private int $statusCode = 200;
|
||||
private array $headers = [];
|
||||
private string $body = '';
|
||||
private bool $sent = false;
|
||||
|
||||
/**
|
||||
* Set the HTTP status code for the response
|
||||
*/
|
||||
public function status(int $code): Response
|
||||
{
|
||||
$this->statusCode = $code;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a header for the response
|
||||
*/
|
||||
public function header(string $name, string $value): Response
|
||||
{
|
||||
$this->headers[$name] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a JSON response with the given data and status code
|
||||
*/
|
||||
public function json(mixed $data, int $status = 200): Response
|
||||
{
|
||||
$this->statusCode = $status;
|
||||
$this->headers['Content-Type'] = 'application/json';
|
||||
$this->body = json_encode($data);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a text response with the given text and status code
|
||||
*/
|
||||
public function text(string $text, int $status = 200): Response
|
||||
{
|
||||
$this->statusCode = $status;
|
||||
$this->headers['Content-Type'] = 'text/plain';
|
||||
$this->body = $text;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an HTML response with the given HTML and status code
|
||||
*/
|
||||
public function html(string $html, int $status = 200): Response
|
||||
{
|
||||
$this->statusCode = $status;
|
||||
$this->headers['Content-Type'] = 'text/html; charset=utf-8';
|
||||
$this->body = $html;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the given URL with the given status code
|
||||
*/
|
||||
public function redirect(string $url, int $status = 302): Response
|
||||
{
|
||||
$this->statusCode = $status;
|
||||
$this->headers['Location'] = $url;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a cookie with the given name, value, and options
|
||||
*/
|
||||
public function cookie(string $name, string $value, array $options = []): Response
|
||||
{
|
||||
$options = array_merge([
|
||||
'expires' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => false,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
], $options);
|
||||
|
||||
setcookie($name, $value, [
|
||||
'expires' => $options['expires'],
|
||||
'path' => $options['path'],
|
||||
'domain' => $options['domain'],
|
||||
'secure' => $options['secure'],
|
||||
'httponly' => $options['httponly'],
|
||||
'samesite' => $options['samesite']
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the response to the client
|
||||
*/
|
||||
public function send(): void
|
||||
{
|
||||
if ($this->sent) {
|
||||
return;
|
||||
}
|
||||
|
||||
http_response_code($this->statusCode);
|
||||
|
||||
foreach ($this->headers as $name => $value) {
|
||||
header("$name: $value");
|
||||
}
|
||||
|
||||
echo $this->body;
|
||||
$this->sent = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the given content to the response body
|
||||
*/
|
||||
public function write(string $content): Response
|
||||
{
|
||||
$this->body .= $content;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* End the response with the given content
|
||||
*/
|
||||
public function end(string $content = ''): void
|
||||
{
|
||||
if ($content) {
|
||||
$this->body .= $content;
|
||||
}
|
||||
$this->send();
|
||||
}
|
||||
}
|
||||
183
router.php
Normal file
183
router.php
Normal file
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
|
||||
/**
|
||||
* Add a route to the route tree. The route must be a URI path, and contain dynamic segments
|
||||
* using a colon prefix (:id, :slug, etc)
|
||||
*
|
||||
* Example:
|
||||
* `$r->add('GET', '/posts/:id', function($id) { echo "Viewing post $id"; });`
|
||||
*/
|
||||
public function add(string $method, string $route, callable $handler): Router
|
||||
{
|
||||
// expand the route into segments and make dynamic segments into a common placeholder
|
||||
$segments = array_map(function($segment) {
|
||||
return str_starts_with($segment, ':') ? ':x' : $segment;
|
||||
}, explode('/', trim($route, '/')));
|
||||
|
||||
// push each segment into the routes array as a node, except if this is the root node
|
||||
$node = &$this->routes;
|
||||
foreach ($segments as $segment) {
|
||||
// skip an empty segment, which allows us to register handlers for the root node
|
||||
if ($segment === '') continue;
|
||||
$node = &$node[$segment]; // build the node tree as we go
|
||||
}
|
||||
|
||||
// Add the handler to the last node
|
||||
$node[$method] = $handler;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a lookup in the route tree for a given method and URI. Returns an array with a result code,
|
||||
* a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and
|
||||
* 405 for method not allowed
|
||||
*
|
||||
* @return array ['code', 'handler', 'params']
|
||||
*/
|
||||
public function lookup(string $method, string $uri): array
|
||||
{
|
||||
// normalize uri to be tolerant of trailing slashes
|
||||
$uri = '/' . trim($uri, '/');
|
||||
|
||||
// node is a reference to our current location in the node tree
|
||||
$node = $this->routes;
|
||||
|
||||
// init the response array
|
||||
$res = ['code' => 0, 'handler' => null, 'params' => []];
|
||||
|
||||
// if the URI is just a slash, we can return the handler for the root node
|
||||
if ($uri === '/') {
|
||||
if (!$this->checkForHandlers($node)) {
|
||||
$res['code'] = 404;
|
||||
return $res;
|
||||
}
|
||||
|
||||
if (isset($node[$method])) {
|
||||
$res['code'] = 200;
|
||||
$res['handler'] = $node[$method];
|
||||
} else {
|
||||
$res['code'] = 405;
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
// we'll split up the URI into segments and traverse the node tree
|
||||
foreach (explode('/', trim($uri, '/')) as $segment) {
|
||||
// check that the next segment is an array (not a callable) and exists, then move to it
|
||||
if (isset($node[$segment]) && is_array($node[$segment])) {
|
||||
$node = $node[$segment];
|
||||
continue;
|
||||
}
|
||||
|
||||
// if there is a dynamic segment, move to it and store the value
|
||||
if (isset($node[':x'])) {
|
||||
$res['params'][] = $segment;
|
||||
$node = $node[':x'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// if we can't find a node for this segment, return 404
|
||||
$res['code'] = 404;
|
||||
return $res;
|
||||
}
|
||||
|
||||
// if no handlers exist at this node, it's not a valid endpoint - return 404
|
||||
if (!$this->checkForHandlers($node)) {
|
||||
$res['code'] = 404;
|
||||
return $res;
|
||||
}
|
||||
|
||||
// if we found a handler for the method, return it and any params. if not, return a 405
|
||||
if (isset($node[$method])) {
|
||||
$res['code'] = 200;
|
||||
$res['handler'] = $node[$method];
|
||||
} else {
|
||||
$res['code'] = 405;
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all routes from the router
|
||||
*/
|
||||
public function clear(): Router
|
||||
{
|
||||
$this->routes = [];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the route tree as an array
|
||||
*/
|
||||
public function dump(): array
|
||||
{
|
||||
return $this->routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given node has any method handlers
|
||||
*/
|
||||
private function checkForHandlers(array $node): bool
|
||||
{
|
||||
foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as $m)
|
||||
if (isset($node[$m]))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a GET route
|
||||
*/
|
||||
public function get(string $route, callable $handler): Router
|
||||
{
|
||||
return $this->add('GET', $route, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a POST route
|
||||
*/
|
||||
public function post(string $route, callable $handler): Router
|
||||
{
|
||||
return $this->add('POST', $route, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a PUT route
|
||||
*/
|
||||
public function put(string $route, callable $handler): Router
|
||||
{
|
||||
return $this->add('PUT', $route, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a PATCH route
|
||||
*/
|
||||
public function patch(string $route, callable $handler): Router
|
||||
{
|
||||
return $this->add('PATCH', $route, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a DELETE route
|
||||
*/
|
||||
public function delete(string $route, callable $handler): Router
|
||||
{
|
||||
return $this->add('DELETE', $route, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a HEAD route
|
||||
*/
|
||||
public function head(string $route, callable $handler): Router
|
||||
{
|
||||
return $this->add('HEAD', $route, $handler);
|
||||
}
|
||||
}
|
||||
163
web.php
Normal file
163
web.php
Normal file
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/methods.php';
|
||||
require_once __DIR__ . '/request.php';
|
||||
require_once __DIR__ . '/response.php';
|
||||
require_once __DIR__ . '/context.php';
|
||||
require_once __DIR__ . '/router.php';
|
||||
|
||||
/**
|
||||
* Web is the application controller itself
|
||||
*/
|
||||
class Web
|
||||
{
|
||||
private Router $router;
|
||||
private array $middleware = [];
|
||||
private Context $context;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->router = new Router();
|
||||
}
|
||||
|
||||
public function use(callable $middleware): self
|
||||
{
|
||||
$this->middleware[] = $middleware;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function get(string $route, callable $handler): self
|
||||
{
|
||||
$this->router->get($route, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function post(string $route, callable $handler): self
|
||||
{
|
||||
$this->router->post($route, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function put(string $route, callable $handler): self
|
||||
{
|
||||
$this->router->put($route, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function patch(string $route, callable $handler): self
|
||||
{
|
||||
$this->router->patch($route, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function delete(string $route, callable $handler): self
|
||||
{
|
||||
$this->router->delete($route, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function head(string $route, callable $handler): self
|
||||
{
|
||||
$this->router->head($route, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function route(string $method, string $route, callable $handler): self
|
||||
{
|
||||
$this->router->add($method, $route, $handler);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function group(string $prefix, callable $callback): self
|
||||
{
|
||||
$originalRouter = $this->router;
|
||||
$groupRouter = new Router();
|
||||
|
||||
$this->router = $groupRouter;
|
||||
$callback($this);
|
||||
|
||||
foreach ($groupRouter->dump() as $path => $methods) {
|
||||
$this->addGroupRoutes($originalRouter, $prefix, $path, $methods);
|
||||
}
|
||||
|
||||
$this->router = $originalRouter;
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function addGroupRoutes(Router $router, string $prefix, string $path, mixed $node, string $currentPath = ''): void
|
||||
{
|
||||
if ($path !== '') {
|
||||
$currentPath = $currentPath ? "$currentPath/$path" : $path;
|
||||
}
|
||||
|
||||
if (!is_array($node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($node as $key => $value) {
|
||||
if (in_array($key, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])) {
|
||||
$fullPath = rtrim($prefix, '/') . '/' . ltrim($currentPath, '/');
|
||||
$fullPath = str_replace(':x', ':param', $fullPath);
|
||||
$router->add($key, $fullPath, $value);
|
||||
} elseif (is_array($value)) {
|
||||
$this->addGroupRoutes($router, $prefix, $key, $value, $currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->context = new Context();
|
||||
|
||||
try {
|
||||
$next = function() {
|
||||
$result = $this->router->lookup(
|
||||
$this->context->request->method,
|
||||
$this->context->request->path
|
||||
);
|
||||
|
||||
if ($result['code'] === 404) {
|
||||
$this->context->error(404);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result['code'] === 405) {
|
||||
$this->context->error(405);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->context->request->params = array_combine(
|
||||
array_map(fn($i) => $i, array_keys($result['params'])),
|
||||
$result['params']
|
||||
);
|
||||
|
||||
$handler = $result['handler'];
|
||||
$response = $handler($this->context, ...$result['params']);
|
||||
|
||||
if ($response instanceof Response) {
|
||||
$response->send();
|
||||
} elseif (is_array($response) || is_object($response)) {
|
||||
$this->context->json($response);
|
||||
} elseif (is_string($response)) {
|
||||
$this->context->text($response);
|
||||
}
|
||||
};
|
||||
|
||||
$chain = array_reduce(
|
||||
array_reverse($this->middleware),
|
||||
function($next, $middleware) {
|
||||
return function() use ($middleware, $next) {
|
||||
$middleware($this->context, $next);
|
||||
};
|
||||
},
|
||||
$next
|
||||
);
|
||||
|
||||
$chain();
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log($e->getMessage());
|
||||
$this->context->error(500, 'Internal Server Error');
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user