From 13aa8deeb78c2f08d9a5cbb1720972fe9ce3cc7a Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Wed, 2 Jun 2021 19:55:25 +0200 Subject: [PATCH 1/4] Add support for webhooks --- composer.json | 8 +- src/PSR15/WebHookMiddleware.php | 47 +++++++++++ src/PSR7/WebHookServerRequestValidator.php | 94 ++++++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/PSR15/WebHookMiddleware.php create mode 100644 src/PSR7/WebHookServerRequestValidator.php diff --git a/composer.json b/composer.json index 3f573380..abfeff9a 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/PSR15/WebHookMiddleware.php b/src/PSR15/WebHookMiddleware.php new file mode 100644 index 00000000..c96e3056 --- /dev/null +++ b/src/PSR15/WebHookMiddleware.php @@ -0,0 +1,47 @@ +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); + } +} diff --git a/src/PSR7/WebHookServerRequestValidator.php b/src/PSR7/WebHookServerRequestValidator.php new file mode 100644 index 00000000..7b54676f --- /dev/null +++ b/src/PSR7/WebHookServerRequestValidator.php @@ -0,0 +1,94 @@ +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->hasPath($event)) { + throw NoOperation::fromPathAndMethod($event, $method); + } + + // there are multiple matching operations, this is bad, because if none of them match the message + // then we cannot say reliably which one intended to match + foreach ($matchingOperationsAddrs as $matchedAddr) { + try { + $this->validator->validate($matchedAddr, $serverRequest); + + return $matchedAddr; // Good, operation matched and request is valid against it, stop here + } catch (Throwable $e) { + // that operation did not match + } + } + + // 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 PathFinder($this->openApi, (string) $request->getUri(), $request->getMethod()); + + return $pathFinder->search(); + } +} From 00134e44479f71aefbfc528ccd5fe12dc4cfbe20 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Mon, 7 Jun 2021 00:18:08 +0200 Subject: [PATCH 2/4] WIP --- src/PSR7/WebHookAddress.php | 141 +++++++++++++++++++++ src/PSR7/WebHookFinder.php | 95 ++++++++++++++ src/PSR7/WebHookServerRequestValidator.php | 19 +-- test.php | 12 ++ 4 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 src/PSR7/WebHookAddress.php create mode 100644 src/PSR7/WebHookFinder.php create mode 100644 test.php diff --git a/src/PSR7/WebHookAddress.php b/src/PSR7/WebHookAddress.php new file mode 100644 index 00000000..928f7a9b --- /dev/null +++ b/src/PSR7/WebHookAddress.php @@ -0,0 +1,141 @@ +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|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) . '#'; + } +} diff --git a/src/PSR7/WebHookFinder.php b/src/PSR7/WebHookFinder.php new file mode 100644 index 00000000..16ca5d84 --- /dev/null +++ b/src/PSR7/WebHookFinder.php @@ -0,0 +1,95 @@ +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; + } +} diff --git a/src/PSR7/WebHookServerRequestValidator.php b/src/PSR7/WebHookServerRequestValidator.php index 7b54676f..4aa03a8b 100644 --- a/src/PSR7/WebHookServerRequestValidator.php +++ b/src/PSR7/WebHookServerRequestValidator.php @@ -58,22 +58,15 @@ public function validate(ServerRequestInterface $serverRequest): Schema $event = $serverRequest->getHeaderLine('X-GitHub-Event'); $method = strtolower($serverRequest->getMethod()); - if (! $this->openApi->webhooks->hasPath($event)) { + if (! $this->openApi->webhooks->hasWebHook($event)) { throw NoOperation::fromPathAndMethod($event, $method); } - // there are multiple matching operations, this is bad, because if none of them match the message - // then we cannot say reliably which one intended to match - foreach ($matchingOperationsAddrs as $matchedAddr) { - try { - $this->validator->validate($matchedAddr, $serverRequest); - - return $matchedAddr; // Good, operation matched and request is valid against it, stop here - } catch (Throwable $e) { - // that operation did not match - } +// 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); } @@ -87,7 +80,7 @@ public function validate(ServerRequestInterface $serverRequest): Schema */ private function findMatchingOperations(ServerRequestInterface $request): array { - $pathFinder = new PathFinder($this->openApi, (string) $request->getUri(), $request->getMethod()); + $pathFinder = new WebHookFinder($this->openApi, $request->getHeaderLine('X-GitHub-Event'), $request->getMethod()); return $pathFinder->search(); } diff --git a/test.php b/test.php new file mode 100644 index 00000000..79dd0e1d --- /dev/null +++ b/test.php @@ -0,0 +1,12 @@ +validate(new ServerRequest('POST', '/webhook', ['X-GitHub-Event' => 'installation'])); From b780ee98af1e234c56f0c02b775885ecffe3cf64 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Mon, 7 Jun 2021 20:05:13 +0200 Subject: [PATCH 3/4] Small fix --- src/Schema/Keywords/Type.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Schema/Keywords/Type.php b/src/Schema/Keywords/Type.php index 17d027f2..3cc34e72 100644 --- a/src/Schema/Keywords/Type.php +++ b/src/Schema/Keywords/Type.php @@ -74,6 +74,12 @@ public function validate($data, string $type, ?string $format = null): void throw TypeMismatch::becauseTypeDoesNotMatch(CebeType::STRING, $data); } + break; + case CebeType::NULL: + if ($data !== null) { + throw TypeMismatch::becauseTypeDoesNotMatch(CebeType::NULL, $data); + } + break; default: throw InvalidSchema::becauseTypeIsNotKnown($type); From 739d63ecfbbfe965151102fd27063c09b78cf384 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Mon, 7 Jun 2021 20:15:16 +0200 Subject: [PATCH 4/4] Another smalle fix --- src/Schema/SchemaValidator.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Schema/SchemaValidator.php b/src/Schema/SchemaValidator.php index 55471b44..4daae60d 100644 --- a/src/Schema/SchemaValidator.php +++ b/src/Schema/SchemaValidator.php @@ -10,6 +10,7 @@ use League\OpenAPIValidation\Schema\Exception\SchemaMismatch; use League\OpenAPIValidation\Schema\Keywords\AllOf; use League\OpenAPIValidation\Schema\Keywords\AnyOf; +use League\OpenAPIValidation\Schema\Keywords\BaseKeyword; use League\OpenAPIValidation\Schema\Keywords\Enum; use League\OpenAPIValidation\Schema\Keywords\Items; use League\OpenAPIValidation\Schema\Keywords\Maximum; @@ -66,7 +67,14 @@ public function validate($data, CebeSchema $schema, ?BreadCrumb $breadCrumb = nu // The following properties are taken from the JSON Schema definition but their definitions were adjusted to the OpenAPI Specification. if (isset($schema->type)) { - (new Type($schema))->validate($data, $schema->type, $schema->format); + if (is_string($schema->type)) { + (new Type($schema))->validate($data, $schema->type, $schema->format); + } else if (is_array($schema->type)) { + foreach ($schema->type as $schemaType) { + (new Type($schema))->validate($data, $schemaType, $schema->format); + break; + } + } } // This keywords come directly from JSON Schema Validation, they are the same as in JSON schema