2
0

initial commit

This commit is contained in:
Sky Johnson 2025-09-10 21:30:50 -05:00
commit 1b65ee2462
6 changed files with 723 additions and 0 deletions

95
context.php Normal file
View 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
View 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
View 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
View 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
View 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
View 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');
}
}
}