基于 PSR-7 和 PSR-17 标准的声明式 PSR-15 路由器

1.0.0 2024-08-26 19:19 UTC

This package is auto-updated.

Last update: 2024-09-26 20:38:08 UTC


README

声明式 PSR-15 路由器

本包是基于 PSR-7、PSR-15 和 PSR-17 标准的 PHP 路由器库。其理念是,对于小型应用程序,您很少需要可编程路由类提供的灵活性,但通常可以通过将路由直接映射到某些资源来满足需求。

尽管路由器的主要理念是拥抱其核心概念的简单性,但它确实允许进行相当高级的配置和自定义使用,使其成为各种用例的合适工具,特别是平面文件内容框架。

安装

该库需要 PHP 8.2+,并可以使用 composer 进行安装。

composer require nsrosenqvist/carte

默认情况下,仅支持 PHP 和 Json 清单文件,但您可以通过添加 adhocore/json-commentsymfony/yaml 作为额外依赖项轻松启用 JsonC 或 Yaml 支持。

兼容性

目前我们支持 PHP 8.2 及以上版本,但对于未来版本,我们不保证支持特定的 PHP 版本。

使用方法

用户可以使用结构化数据格式(如 Yaml 或 Json)定义其应用程序的路由(我们自带对 Yaml、Json、JsonC 和 PHP 数组文件的支持)。通配符模式匹配、分组、中间件和自定义响应都可以通过其格式轻松配置。

index:
  body: 'Welcome!'
blog/*:
  body: 'php://handler.php'
about:
  routes:
    me:
      body: 'md://who-am-i.md'
    writings:
      body: 'md://writings.md'
contact:
  - body: 'php://mailer.php'
    match:
      method: POST
  - body: 'file://contact.html'
    match:
      method: GET
subscribers:
  strategy: auth
  body: 'Thank you!'

注意

由于 JsonC 具有更好的数据结构可读性,将使用 JsonC 编写更多示例。

请注意,由于本包仅是路由器而不是框架,因此必须在该路由器周围实现服务器请求处理。以下示例是使用 Guzzle 的 PSR-17 工厂实现(guzzlehttp/psr7)的一个示例实现。

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Carte\Content\FileResolver;
use Carte\Content\PhpResolver;
use Carte\Content\RedirectResolver;
use Carte\Content\StreamResolver;
use Carte\Manifest;
use Carte\Router;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\Psr7\ServerRequest;

// Handle the request
$factory = new HttpFactory();
$manifest = new Manifest(__DIR__ . '/site.yml', __DIR__ . '/cache.php');
$router = new Router(
    responseFactory: $factory,
    streamFactory: $factory,
    manifest: $manifest,
    resolvers: [
        new StreamResolver(__DIR__ . '/content', $factory),
        new PhpResolver(__DIR__ . '/content'),
        new FileResolver(__DIR__ . '/content'),
        new RedirectResolver(),
    ],
);

$request = ServerRequest::fromGlobals();
$response = $router->dispatch($request);

// Emit the status code
http_response_code($response->getStatusCode());

// Emit the headers
foreach ($response->getHeaders() as $name => $values) {
    foreach ($values as $value) {
        header(sprintf('%s: %s', $name, $value), false);
    }
}

// Emit the body
echo $response->getBody();

配置路由器

在实例化路由器时,可以通过构造函数设置其所有配置选项。或者,也可以使用相应的设置器以编程方式设置它们。唯一的非可选参数如下:

/**
 * @param ResponseFactoryInterface   $responseFactory PSR-17 response factory
 * @param StreamFactoryInterface     $streamFactory   PSR-17 stream factory
 * @param Manifest                   $manifest        Route manifest
 */

内容解析器

内容解析器是定义如何从匹配的路由构造响应的方法。稍后部分将详细描述这些内容。

默认策略

策略是一组 PSR-15 中间件。这些可以在每个路由级别或组级别定义,但如果需要在所有请求上运行中间件,则可以在全局此处定义策略。

默认响应

如果没有匹配到路由,可以选择提供默认的 PSR-7 响应。这也可以通过路由器清单轻松定义,作为通配符路由。

优雅失败

默认情况下,路由器不会抛出任何异常,而是将其转换为适当的 HTTP 响应。如果想要手动处理这些异常,可以将路由器设置为不处理这些异常,并使异常向上冒泡。

根目录

如果应用程序不是在应用程序根目录上提供服务的,则可以在路由器上配置根 URI,以便将请求 https://foo.bar/root/about 正确映射到清单中定义的 about 路由。如果同时使用重定向内容解析器,必须确保也为其配置根。

清单格式

路由器清单定义了应针对不同请求提供哪些响应。这是一个相当灵活的语法,旨在帮助您编写尽可能少的配置。基本语法如下。

{
    // Request URI
    "foo/bar": {
        "code": 200,                     // Return code
        "body": "Lorem Ipsum",           // Response body
        "headers": {                     // Response headers
            "Content-Type": "text/plain" // This isn't required, we will attempt to set the mime type automatically
        },
        "reason": "OK",                  // Reason phrase
        "version": "1.1",                // Protocol version

        // Any extra parameters will be mapped to the matched route's "extra" property,
        // These can also be set more explicitly under a property named "extra"
        "foo": "bar"
    }
}

属性body可以是简单的字符串,也可以是任何已注册内容解析器可以处理的形式。默认内容解析器都是设计用来使用URI来正确委派内容解析的,即redirect://somewhere

路由定义基本上可以是空的,因为响应将由默认值填充。

分组路由

可以在路由前缀下分组路由,以便将选择的策略或额外属性传播到所有子路由。也支持嵌套分组路由。一个分组由具有“routes”属性的条目定义,其中包含子定义。

{
    // Route group
    "about": {
        // Custom properties will be set on all children as well
        "extra": {
            "theme": "fun"
        },
        // Child routes
        "routes": {
            "history": {
                "body": "My background..."
            },
            "plans": {
                "body": "My future plans!"
            },
            "dogs": {
                "body": "My pets!"
            }
        }
    }
}

变量匹配

可以通过将它们包装在数组中并使用匹配语句来为相同的路由创建多个响应定义。在下面的示例中,对foo/bar/lorem的请求会产生200响应,而对foo/bar/ipsum的请求会产生202。

{
    // Named variable in route definition
    "foo/bar/{var}": [
        {
            "code": 200,
            "match": {
                "var": "lorem"
            }
        },
        {
            "code": 202,
            "match": {
                "var": "ipsum"
            }
        }
    ]
}

变量名“query”和“method”是保留的,因为它们用于定义方法和查询匹配的条件。

通配符模式

甚至可以混合使用命名变量和glob通配符。

{
    // Named variable in route definition
    "foo/bar/{var}": [
        // ...
    ],
    "foo/bar/*": {
        "code": 404
    }
}

路由将根据特定性进行匹配,因此命名变量将优先于通配符。底层通配符匹配使用fnmatch,因此可以使用更复杂的模式,但它们不是官方支持的。

方法匹配

匹配定义中的方法键允许为不同的方法指定不同的响应。

{
    "foo/bar": [
        {
            "code": 200,
            "match": {
                "method": "GET"
            }
        },
        {
            "code": 202,
            "match": {
                "method": "POST"
            }
        }
    ]
}

查询条件

除了变量匹配之外,还可以测试查询参数(这些也将按照特定性顺序优先级排序)。

{
    "foo/bar": [
        {
            "code": 200,
            "match": {
                "query": {
                    "type": "foo"
                }
            }
        },
        {
            "code": 202,
            "match": {
                "query": {
                    "type": "bar"
                }
            }
        }
    ]
}
高级查询匹配

除了直接比较之外,还可以设置这些特殊比较运算符中的任何一个

  • __isset__:测试参数是否存在。
  • __missing__:测试参数是否存在。
  • __true__:测试参数是否为真(这包括“yes”、“y”、1等)。
  • __false__:测试参数是否为假(这包括“no”、“n”、0等)。
  • __bool__:测试参数是否为布尔值。
  • __string__:测试参数是否为字符串。
  • __numeric__:测试参数是否为数字。
  • __int__:测试参数是否为整型。
  • __float__:测试参数是否为浮点型。
  • __array__:测试参数是否为数组。

替代语法

为了最小化所需的配置,还支持一些替代语法。这些将在导入时进行标准化。

短语法
{
    "alternative/syntax/short-code": 100,                // Will return a response with status 100
    "alternative/syntax/short-content": "Response body", // Will by default return a 200 response with the body "Response body"
}
REST语法

REST语法允许根据HTTP方法定义响应(方法将被扩展为match->method,就像常规方法匹配一样)。这种语法也支持短语法定义。

{
    "alternative/syntax/rest": {
        "GET": "Response body",
        "POST": 204,
        "*": { // Catch-all definition is also supported
            "body": "How did you get here?",
            "code": 404 
        }
    }
}

中间件策略

库还支持使用策略实现配置的PSR-15中间件。“策略”是一组可重用的中间件,其类需要实现\Carte\Strategies\StrategyInterface

declare(strict_types=1);

use Carte\Strategies\Strategy;
use MyFirstMiddleware;
use MySecondMiddleware;

class MyStrategy extends Strategy
{
    public function __construct() {
        parent::__construct([
            new MyFirstMiddleware(),
            new MySecondMiddleware(),
        ]);
    }
}

要为路由定义策略,可以按路由或分组级别设置。

{
    // Route group
    "about": {
        "strategy": "custom",
        "routes": {
            // Strategy will be propagated to all children
            // ...
        }
    },
    // Single route
    "contact": {
        "strategy": "custom"
    }
}

内容解析

内容解析器通过处理传入的请求对象和匹配的路由来返回响应的内容。这是您使用“额外”属性的地方。有几个内置的解析器,您可以根据在路由定义中如何指定“body”属性来使用它们。

文件解析器

当您指定带有“file”URI方案的路径时,将选择文件解析器:file://myfile.txt。解析器将尝试在类实例配置的资源目录下找到该文件,并自动确定响应的内容类型。

$resolver = new \Carte\Content\FileResolver(__DIR__ . '/content');

PHP解析器

当您指定带有 "php" URI 方案的路径时,将选择 PHP 解析器:php://handler.php。解析器会将该 PHP 文件加载到内容解析器的执行上下文中。这是最灵活的默认解析器,因为您自己处理执行逻辑。执行将要执行的 PHP 文件将在其环境中定义以下变量,用于处理请求。

/**
 * @var \Carte\Routes\RouteCase                  $route
 * @var \Psr\Http\Message\ServerRequestInterface $request
 * @var int                                      $httpCode
 * @var array<string, string>                    $httpHeaders
 */

$httpCode$httpHeaders 都通过引用传递,可以用来修改返回的响应。执行过程中输出的内容将填充响应体。

$resolver = new \Carte\Content\PhpResolver(__DIR__ . '/handlers');

重定向解析器

当您指定带有 "redirect" URI 方案的路径(如 redirect://about)或指定外部地址并设置响应代码为 30X 时,将选择重定向解析器。

{
    "code": 302,
    "body": "https://foo.bar/"
}

在使用重定向 URI 方案时,解析器将其视为内部(同站)重定向,并通过使用来自当前请求的主机信息构建地址,将用户重定向到另一个路由。如果路由器正在处理位于网站根路径下的路径,则在实例化解析器时必须配置此根路径。

$resolver = new \Carte\Content\RedirectResolver('under/root');

流解析器

当您指定带有 "stream" URI 方案的路径时(如 stream://image.jpg),将选择流解析器。它将创建一个 PSR-7 流响应而不是正常消息。在实例化时,必须提供 PSR-17 流工厂实现。

$factory = new \GuzzleHttp\Psr7\HttpFactory();
$resolver = new \Carte\Content\StreamResolver(__DIR__ . '/content', $factory);

自定义解析器

如果您想创建自己的解析器,这很容易。例如,如果您正在构建一个提供 markdown 的网站,并且不需要使用 URI 方案来区分不同的解析器,您可以轻松构建一个解析器,它处理所有匹配的路由,并使用 CommonMark 解析指定的文件。

declare(strict_types=1);

namespace Carte\Content;

use Carte\Content\ContentResolverInterface;
use Carte\Exceptions\FileNotFoundException;
use Carte\Routes\RouteCase;
use League\CommonMark\GithubFlavoredMarkdownConverter;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;

class FileResolver implements ContentResolverInterface
{
    protected $markdown;

    /**
     * @throws FileNotFoundException
     */
    public function __construct(
        protected string $resourceDirectory,
    ) {
        $this->markdown = new GithubFlavoredMarkdownConverter();
        $this->resourceDirectory = realpath($this->resourceDirectory) ?: '';

        if (! $this->resourceDirectory || ! is_dir($this->resourceDirectory)) {
            throw new FileNotFoundException("Resource directory not found: $resourceDirectory");
        }
    }

    public function resolve(RouteCase $route, ServerRequestInterface $request, int &$httpCode = 200, array &$httpHeaders = []): StreamInterface|string
    {
        $file = $route->body ?: '';
        $path = "{$this->resourceDirectory}/$file";

        if (! str_starts_with(realpath($path) ?: '', $this->resourceDirectory)) {
            throw new FileNotFoundException("File not found: $file");
        }

        $contents = file_get_contents($path);

        if ($contents === false) {
            throw new FileNotFoundException("File could not be read: $file");
        }

        return $this->markdown->convert($contents);
    }

    public function supports(RouteCase $route): bool
    {
        return true;
    }
}

清单缓存

对于生产用例,编译后的清单文件应该始终缓存。当加载清单时,它会经过几个步骤来扩展替代语法、组以及规范数据格式,以便路由器更快地处理。在实例化清单时指定缓存路径,缓存将自动创建。

注意

当使用定义了缓存路径的清单实例时,在更新清单文件时必须手动删除该缓存,因为没有提供自动缓存失效。以下详细说明了实现缓存失效的简单方法。

$path = __DIR__ . '/site.yml';
$cache = __DIR__ . '/cache.php';

if (filemtime($cache) < filemtime($path)) {
    unlink($cache);
}

$manifest = new \Carte\Manifest($path, $cache);

开发

为了设置您的开发环境,首先确保您已安装 docker,克隆仓库,然后通过运行以下命令启动开发容器:

./app up --detach

./app 是 Docker Compose 的简单包装器,它使得与应用程序容器接口更简单。项目源目录将映射到容器的工作目录。要进入开发 shell,请运行

./app /bin/sh

从那里,您可以运行 composer install 和其他定义的命令。

注意

在容器中开发使我们能够轻松验证库是否按预期针对目标 PHP 版本工作。

当执行 composer install 时,应自动配置某些钩子,以确保在推送更改之前遵守代码规范。在提交 PR 之前,请确保您的代码审查、静态分析和单元测试都通过。请参阅 composer.json 以获取配置的命令(例如 testlintanalyze)。

许可证

本库采用 MIT 许可,但 src/Http/Method.php 文件部分采用 Boost 软件许可协议 - 第 1.0 版,这是因为它是包 alexanderpas/http-enum 的一部分。然而,所有文件的添加都采用 MIT 许可。