Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,16 @@
"League\\OpenAPIValidation\\Tests\\": "tests/"
}
},
"repositories": [
{
"type": "vcs",
"url": "git@github.com:WyriHaximus-labs/php-openapi.git"
}
],
"require": {
"php": ">=7.2",
"ext-json": "*",
"cebe/php-openapi": "^1.3",
"cebe/php-openapi": "dev-webhooks as 1.4.0",
"league/uri": "^6.3",
"psr/cache": "^1.0 || ^2.0 || ^3.0",
"psr/http-message": "^1.0",
Expand Down
47 changes: 47 additions & 0 deletions src/PSR15/WebHookMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace League\OpenAPIValidation\PSR15;

use League\OpenAPIValidation\PSR15\Exception\InvalidResponseMessage;
use League\OpenAPIValidation\PSR15\Exception\InvalidServerRequestMessage;
use League\OpenAPIValidation\PSR7\Exception\ValidationFailed;
use League\OpenAPIValidation\PSR7\ResponseValidator;
use League\OpenAPIValidation\PSR7\ServerRequestValidator;
use League\OpenAPIValidation\PSR7\WebHookServerRequestValidator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class WebHookMiddleware implements MiddlewareInterface
{
public const SCHEMA_ATTRIBUTE = 'aopuhend opaijwefoi joiawpjoi pefoa p2e';

/** @var WebHookServerRequestValidator */
private $requestValidator;

public function __construct(WebHookServerRequestValidator $requestValidator)
{
$this->requestValidator = $requestValidator;
}

/**
* Process an incoming server request.
*
* Processes an incoming server request in order to produce a response.
* If unable to produce the response itself, it may delegate to the provided
* request handler to do so.
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$request = $request->withAttribute(self::SCHEMA_ATTRIBUTE, $this->requestValidator->validate($request));
} catch (ValidationFailed $e) {
throw InvalidServerRequestMessage::because($e);
}

return $handler->handle($request);
}
}
141 changes: 141 additions & 0 deletions src/PSR7/WebHookAddress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

declare(strict_types=1);

namespace League\OpenAPIValidation\PSR7;

use League\OpenAPIValidation\PSR7\Exception\Validation\InvalidPath;
use League\OpenAPIValidation\Schema\Exception\InvalidSchema;

use function implode;
use function preg_match;
use function preg_quote;
use function preg_replace;
use function preg_split;
use function sprintf;
use function strtok;

use const PREG_SPLIT_DELIM_CAPTURE;

class WebHookAddress
{
/** @var string */
protected $method;
/** @var string */
protected $path;

public function __construct(string $path, string $method)
{
$this->path = $path;
$this->method = $method;
}

/**
* Checks if path matches a specification
*
* @param string $specPath like "/users/{id}"
* @param string $path like "/users/12"
*/
public static function isPathMatchesSpec(string $specPath, string $path): bool
{
$pattern = '#^' . preg_replace('#{[^}]+}#', '[^/]+', $specPath) . '/?$#';

return (bool) preg_match($pattern, $path);
}

public function method(): string
{
return $this->method;
}

public function __toString(): string
{
return sprintf('Request [%s %s]', $this->method, $this->path);
}

public function path(): string
{
return $this->path;
}

/**
* Parses given URL and returns params according to the pattern.
*
* Example:
* $specPath = "/users/{id}";
* $url = "/users/12";
* returns ["id"=>12]
*
* @param string $url as seen in actual HTTP Request/ServerRequest
*
* @return mixed[] return array of ["paramName"=>"parsedValue", ...]
*
* @throws InvalidPath
*/
public function parseParams(string $url): array
{
// pattern: /a/{b}/c/{d}
// actual: /a/12/c/some
// result: ['b'=>'12', 'd'=>'some']

// 0. Filter URL, remove query string
$url = strtok($url, '?');

// 1. Find param names and build pattern
$pattern = $this->buildPattern($this->path(), $parameterNames);

// 2. Parse param values
if (! preg_match($pattern, $url, $matches)) {
throw InvalidPath::becausePathDoesNotMatchPattern($url, $this);
}

// 3. Combine keys and values
$parsedParams = [];
foreach ($parameterNames as $name) {
$parsedParams[$name] = $matches[$name];
}

return $parsedParams;
}

/**
* It builds PCRE pattern, which can be used to parse path. It also extract parameter names
*
* @param array<string>|null $parameterNames
*/
protected function buildPattern(string $url, ?array &$parameterNames): string
{
$parameterNames = [];
$pregParts = [];
$inParameter = false;

$parts = preg_split('#([{}])#', $url, -1, PREG_SPLIT_DELIM_CAPTURE);
foreach ($parts as $part) {
switch ($part) {
case '{':
if ($inParameter) {
throw InvalidSchema::becauseBracesAreNotBalanced($url);
}

$inParameter = true;
continue 2;
case '}':
if (! $inParameter) {
throw InvalidSchema::becauseBracesAreNotBalanced($url);
}

$inParameter = false;
continue 2;
}

if ($inParameter) {
$pregParts[] = '(?<' . $part . '>[^/]+)';
$parameterNames[] = $part;
} else {
$pregParts[] = preg_quote($part, '#');
}
}

return '#' . implode($pregParts) . '#';
}
}
95 changes: 95 additions & 0 deletions src/PSR7/WebHookFinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace League\OpenAPIValidation\PSR7;

use cebe\openapi\spec\OpenApi;
use cebe\openapi\spec\Operation;
use cebe\openapi\spec\PathItem;
use cebe\openapi\spec\Server;

use function count;
use function ltrim;
use function parse_url;
use function preg_match;
use function preg_replace;
use function rtrim;
use function sprintf;
use function strtolower;

use const PHP_URL_PATH;

// This class finds operations matching the given URI+method
// That would be a very simple operation if there were no "Servers" keyword.
// We need to take into account possible base-url case (and its templating feature)
// @see https://swagger.io/docs/specification/api-host-and-base-path/
//
// More: as discussed here https://github.com/lezhnev74/openapi-psr7-validator/issues/32
// "schema://hostname" should not be included in the validation process (assume any hostname matches)
class WebHookFinder
{
/** @var OpenApi */
protected $openApiSpec;
/** @var string */
protected $event;
/** @var string $method like "get" */
protected $method;
/** @var Operation[] */
protected $searchResult;

public function __construct(OpenApi $openApiSpec, string $event, string $method)
{
$this->openApiSpec = $openApiSpec;
$this->event = $event;
$this->method = strtolower($method);
}

/**
* Determine matching paths.
*
* @return PathItem[]
*/
public function getWebHookMatches(): array
{
// Determine if path matches exactly.
$match = $this->openApiSpec->webhooks->getWebHook($this->event);
if ($match !== null) {
return [$match];
}

return [];
}

/**
* @return Operation[]
*/
public function search(): array
{
if ($this->searchResult === null) {
$this->searchResult = $this->doSearch();
}

return $this->searchResult;
}

/**
* @return Operation[]
*/
private function doSearch(): array
{
$matchedOperations = [];

if ($this->openApiSpec->webhooks->hasWebHook($this->event)) {
foreach ($this->openApiSpec->webhooks->getWebHook($this->event)->getOperations() as $opMethod => $operation) {
if ($opMethod !== $this->method) {
continue;
}

$matchedOperations[] = $operation;
}
}

return $matchedOperations;
}
}
87 changes: 87 additions & 0 deletions src/PSR7/WebHookServerRequestValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace League\OpenAPIValidation\PSR7;

use cebe\openapi\spec\OpenApi;
use cebe\openapi\spec\Schema;
use League\OpenAPIValidation\PSR7\Exception\MultipleOperationsMismatchForRequest;
use League\OpenAPIValidation\PSR7\Exception\NoOperation;
use League\OpenAPIValidation\PSR7\Exception\ValidationFailed;
use League\OpenAPIValidation\PSR7\Validators\BodyValidator\BodyValidator;
use League\OpenAPIValidation\PSR7\Validators\CookiesValidator\CookiesValidator;
use League\OpenAPIValidation\PSR7\Validators\HeadersValidator;
use League\OpenAPIValidation\PSR7\Validators\PathValidator;
use League\OpenAPIValidation\PSR7\Validators\QueryArgumentsValidator;
use League\OpenAPIValidation\PSR7\Validators\SecurityValidator;
use League\OpenAPIValidation\PSR7\Validators\ValidatorChain;
use Psr\Http\Message\ServerRequestInterface;
use Throwable;

use function count;
use function strtolower;

class WebHookServerRequestValidator implements ReusableSchema
{
/** @var OpenApi */
protected $openApi;
/** @var MessageValidator */
protected $validator;

public function __construct(OpenApi $schema)
{
$this->openApi = $schema;
$finder = new SpecFinder($this->openApi);
$this->validator = new ValidatorChain(
new HeadersValidator($finder),
new CookiesValidator($finder),
new BodyValidator($finder),
new QueryArgumentsValidator($finder),
new PathValidator($finder),
new SecurityValidator($finder)
);
}

public function getSchema(): OpenApi
{
return $this->openApi;
}

/**
* @return Schema matching the webhook event
*
* @throws ValidationFailed
*/
public function validate(ServerRequestInterface $serverRequest): Schema
{
$event = $serverRequest->getHeaderLine('X-GitHub-Event');
$method = strtolower($serverRequest->getMethod());

if (! $this->openApi->webhooks->hasWebHook($event)) {
throw NoOperation::fromPathAndMethod($event, $method);
}

// var_export($this->openApi->webhooks->getWebHook($event));
// var_export($matchingOperationsAddrs = $this->findMatchingOperations($serverRequest));
foreach ($this->findMatchingOperations($serverRequest) as $operation) {
$this->validator->validate(new OperationAddress());
}
// no operation matched at all...
throw MultipleOperationsMismatchForRequest::fromMatchedAddrs($matchingOperationsAddrs);
}

/**
* Check the openapi spec and find matching operations(path+method)
* This should consider path parameters as well
* "/users/12" should match both ["/users/{id}", "/users/{group}"]
*
* @return OperationAddress[]
*/
private function findMatchingOperations(ServerRequestInterface $request): array
{
$pathFinder = new WebHookFinder($this->openApi, $request->getHeaderLine('X-GitHub-Event'), $request->getMethod());

return $pathFinder->search();
}
}
Loading