commit 1b65ee246224dcbffc96e02150357262525041e2 Author: Sky Johnson Date: Wed Sep 10 21:30:50 2025 -0500 initial commit diff --git a/context.php b/context.php new file mode 100644 index 0000000..297b01a --- /dev/null +++ b/context.php @@ -0,0 +1,95 @@ +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); + } + } +} diff --git a/methods.php b/methods.php new file mode 100644 index 0000000..f19b9f7 --- /dev/null +++ b/methods.php @@ -0,0 +1,39 @@ + 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; + } +} diff --git a/request.php b/request.php new file mode 100644 index 0000000..1dc139a --- /dev/null +++ b/request.php @@ -0,0 +1,105 @@ +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; + } +} diff --git a/response.php b/response.php new file mode 100644 index 0000000..744a72e --- /dev/null +++ b/response.php @@ -0,0 +1,138 @@ +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(); + } +} diff --git a/router.php b/router.php new file mode 100644 index 0000000..3927ab8 --- /dev/null +++ b/router.php @@ -0,0 +1,183 @@ +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); + } +} diff --git a/web.php b/web.php new file mode 100644 index 0000000..9656cff --- /dev/null +++ b/web.php @@ -0,0 +1,163 @@ +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'); + } + } +}