From 4d5415d609bc43ee62a91a3bda1513193b02fa25 Mon Sep 17 00:00:00 2001 From: Marcel Frankruijter Date: Wed, 5 Aug 2020 22:53:38 +0200 Subject: [PATCH] The initial implementation of the package --- .gitattributes | 9 + .gitignore | 3 + .travis.yml | 17 ++ CHANGELOG.md | 13 + CODE_OF_CONDUCT.md | 76 +++++ CONTRIBUTING.md | 28 ++ LICENSE | 21 ++ PULL_REQUEST_TEMPLATE.md | 18 ++ README.md | 47 +++ composer.json | 61 ++++ .../http-authorization.configuration.json | 5 + .../route-group.configuration.json | 5 + .../configuration/route.configuration.json | 5 + .../web-mime-to-codec.configuration.json | 5 + .../schema/http-authorization.schema.json | 20 ++ configuration/schema/route-group.schema.json | 45 +++ configuration/schema/route.schema.json | 49 ++++ .../web-mime-to-codec/json.translation.json | 9 + docs/index.md | 12 + docs/usage/authorization.md | 25 ++ docs/usage/endpoint.md | 53 ++++ docs/usage/error.md | 38 +++ docs/usage/index.md | 13 + docs/usage/request-codecs.md | 20 ++ docs/usage/route-group.md | 43 +++ docs/usage/route.md | 46 +++ locator.php | 15 + phpcs.xml | 12 + phpunit.xml | 16 ++ src/Common/Endpoint/EndpointInterface.php | 24 ++ src/Common/Endpoint/InputInterface.php | 55 ++++ src/Common/Endpoint/OutputInterface.php | 152 ++++++++++ src/Common/Error/ErrorHandlerInterface.php | 55 ++++ src/Common/Error/ErrorInterface.php | 27 ++ src/Common/Error/ErrorRegistryInterface.php | 39 +++ src/Common/Factory/OutputFactoryInterface.php | 23 ++ src/Common/Output/HeaderHandlerInterface.php | 27 ++ .../Output/OutputConverterInterface.php | 22 ++ src/Common/Output/OutputHandlerInterface.php | 27 ++ src/Common/Output/StatusCodeEnum.php | 164 +++++++++++ .../AuthorizationRegistryInterface.php | 46 +++ .../Registry/RouteGroupRegistryInterface.php | 48 ++++ .../Request/AuthorizationHandlerInterface.php | 29 ++ src/Common/Request/AuthorizationInterface.php | 27 ++ src/Common/Router/PathMatcherInterface.php | 24 ++ src/Common/Router/RouteGroupInterface.php | 55 ++++ src/Common/Router/RouteInterface.php | 78 +++++ src/Common/Router/RouterInterface.php | 27 ++ src/Common/UlrackWebPackage.php | 16 ++ src/Component/Endpoint/Input.php | 95 +++++++ src/Component/Endpoint/Output.php | 267 +++++++++++++++++ src/Component/Error/ConfigurableApiError.php | 88 ++++++ .../Error/ConfigurablePlainError.php | 81 ++++++ src/Component/Error/ErrorHandler.php | 131 +++++++++ src/Component/Error/ErrorRegistry.php | 68 +++++ src/Component/Output/CodecOutputConverter.php | 65 +++++ src/Component/Output/HeaderHandler.php | 55 ++++ src/Component/Output/OutputHandler.php | 72 +++++ src/Component/Output/PlainOutputConverter.php | 61 ++++ .../Registry/AuthorizationRegistry.php | 77 +++++ src/Component/Registry/RouteGroupRegistry.php | 80 ++++++ .../Request/AuthorizationHandler.php | 104 +++++++ src/Component/Router/GroupRouter.php | 147 ++++++++++ src/Component/Router/PathMatcher.php | 95 +++++++ src/Component/Router/Route.php | 182 ++++++++++++ src/Component/Router/RouteGroup.php | 131 +++++++++ src/Component/Router/Router.php | 269 ++++++++++++++++++ src/Exception/HttpException.php | 49 ++++ src/Exception/MethodNotAllowedException.php | 28 ++ src/Exception/NotAcceptedException.php | 28 ++ src/Exception/NotFoundException.php | 28 ++ src/Exception/UnauthorizedException.php | 28 ++ .../UnsupportedMediaTypeException.php | 28 ++ src/Factory/InputFactory.php | 171 +++++++++++ src/Factory/OutputFactory.php | 71 +++++ tests/Component/Endpoint/InputTest.php | 55 ++++ tests/Component/Endpoint/OutputTest.php | 73 +++++ .../Error/ConfigurableApiErrorTest.php | 53 ++++ .../Error/ConfigurablePlainErrorTest.php | 48 ++++ tests/Component/Error/ErrorHandlerTest.php | 77 +++++ tests/Component/Error/ErrorRegistryTest.php | 41 +++ .../Output/CodecOutputConverterTest.php | 84 ++++++ tests/Component/Output/HeaderHandlerTest.php | 78 +++++ tests/Component/Output/OutputHandlerTest.php | 78 +++++ .../Output/PlainOutputConverterTest.php | 87 ++++++ .../Registry/AuthorizationRegistryTest.php | 37 +++ .../Registry/RouteGroupRegistryTest.php | 39 +++ .../Request/AuthorizationHandlerTest.php | 139 +++++++++ tests/Component/Router/GroupRouterTest.php | 189 ++++++++++++ tests/Component/Router/PathMatcherTest.php | 104 +++++++ tests/Component/Router/RouteGroupTest.php | 54 ++++ tests/Component/Router/RouteTest.php | 75 +++++ tests/Component/Router/RouterTest.php | 255 +++++++++++++++++ tests/Factory/InputFactoryTest.php | 132 +++++++++ tests/Factory/OutputFactoryTest.php | 50 ++++ 95 files changed, 5941 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 PULL_REQUEST_TEMPLATE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 configuration/configuration/http-authorization.configuration.json create mode 100644 configuration/configuration/route-group.configuration.json create mode 100644 configuration/configuration/route.configuration.json create mode 100644 configuration/configuration/web-mime-to-codec.configuration.json create mode 100644 configuration/schema/http-authorization.schema.json create mode 100644 configuration/schema/route-group.schema.json create mode 100644 configuration/schema/route.schema.json create mode 100644 configuration/web-mime-to-codec/json.translation.json create mode 100644 docs/index.md create mode 100644 docs/usage/authorization.md create mode 100644 docs/usage/endpoint.md create mode 100644 docs/usage/error.md create mode 100644 docs/usage/index.md create mode 100644 docs/usage/request-codecs.md create mode 100644 docs/usage/route-group.md create mode 100644 docs/usage/route.md create mode 100644 locator.php create mode 100644 phpcs.xml create mode 100644 phpunit.xml create mode 100644 src/Common/Endpoint/EndpointInterface.php create mode 100644 src/Common/Endpoint/InputInterface.php create mode 100644 src/Common/Endpoint/OutputInterface.php create mode 100644 src/Common/Error/ErrorHandlerInterface.php create mode 100644 src/Common/Error/ErrorInterface.php create mode 100644 src/Common/Error/ErrorRegistryInterface.php create mode 100644 src/Common/Factory/OutputFactoryInterface.php create mode 100644 src/Common/Output/HeaderHandlerInterface.php create mode 100644 src/Common/Output/OutputConverterInterface.php create mode 100644 src/Common/Output/OutputHandlerInterface.php create mode 100644 src/Common/Output/StatusCodeEnum.php create mode 100644 src/Common/Registry/AuthorizationRegistryInterface.php create mode 100644 src/Common/Registry/RouteGroupRegistryInterface.php create mode 100644 src/Common/Request/AuthorizationHandlerInterface.php create mode 100644 src/Common/Request/AuthorizationInterface.php create mode 100644 src/Common/Router/PathMatcherInterface.php create mode 100644 src/Common/Router/RouteGroupInterface.php create mode 100644 src/Common/Router/RouteInterface.php create mode 100644 src/Common/Router/RouterInterface.php create mode 100644 src/Common/UlrackWebPackage.php create mode 100644 src/Component/Endpoint/Input.php create mode 100644 src/Component/Endpoint/Output.php create mode 100644 src/Component/Error/ConfigurableApiError.php create mode 100644 src/Component/Error/ConfigurablePlainError.php create mode 100644 src/Component/Error/ErrorHandler.php create mode 100644 src/Component/Error/ErrorRegistry.php create mode 100644 src/Component/Output/CodecOutputConverter.php create mode 100644 src/Component/Output/HeaderHandler.php create mode 100644 src/Component/Output/OutputHandler.php create mode 100644 src/Component/Output/PlainOutputConverter.php create mode 100644 src/Component/Registry/AuthorizationRegistry.php create mode 100644 src/Component/Registry/RouteGroupRegistry.php create mode 100644 src/Component/Request/AuthorizationHandler.php create mode 100644 src/Component/Router/GroupRouter.php create mode 100644 src/Component/Router/PathMatcher.php create mode 100644 src/Component/Router/Route.php create mode 100644 src/Component/Router/RouteGroup.php create mode 100644 src/Component/Router/Router.php create mode 100644 src/Exception/HttpException.php create mode 100644 src/Exception/MethodNotAllowedException.php create mode 100644 src/Exception/NotAcceptedException.php create mode 100644 src/Exception/NotFoundException.php create mode 100644 src/Exception/UnauthorizedException.php create mode 100644 src/Exception/UnsupportedMediaTypeException.php create mode 100644 src/Factory/InputFactory.php create mode 100644 src/Factory/OutputFactory.php create mode 100644 tests/Component/Endpoint/InputTest.php create mode 100644 tests/Component/Endpoint/OutputTest.php create mode 100644 tests/Component/Error/ConfigurableApiErrorTest.php create mode 100644 tests/Component/Error/ConfigurablePlainErrorTest.php create mode 100644 tests/Component/Error/ErrorHandlerTest.php create mode 100644 tests/Component/Error/ErrorRegistryTest.php create mode 100644 tests/Component/Output/CodecOutputConverterTest.php create mode 100644 tests/Component/Output/HeaderHandlerTest.php create mode 100644 tests/Component/Output/OutputHandlerTest.php create mode 100644 tests/Component/Output/PlainOutputConverterTest.php create mode 100644 tests/Component/Registry/AuthorizationRegistryTest.php create mode 100644 tests/Component/Registry/RouteGroupRegistryTest.php create mode 100644 tests/Component/Request/AuthorizationHandlerTest.php create mode 100644 tests/Component/Router/GroupRouterTest.php create mode 100644 tests/Component/Router/PathMatcherTest.php create mode 100644 tests/Component/Router/RouteGroupTest.php create mode 100644 tests/Component/Router/RouteTest.php create mode 100644 tests/Component/Router/RouterTest.php create mode 100644 tests/Factory/InputFactoryTest.php create mode 100644 tests/Factory/OutputFactoryTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..08526d4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +/tests export-ignore +/examples export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml export-ignore +/phpcs.xml export-ignore +/PULL_REQUEST_TEMPLATE.md export-ignore +/CODE_OF_CONDUCT.md export-ignore +/CONTRIBUTING.md export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9af8130 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +var/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..47134ff --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: php +php: + - '7.3' + - '7.4' + +before_script: + - yes '' | pecl install yaml + - composer install + +script: + - composer validate --strict + - vendor/bin/phpunit --coverage-text + - vendor/bin/phpcs src/ tests/ + +cache: + directories: + - $HOME/.composer/cache diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..08c99f2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.0.0 - 2020-08-08 + +### Added +- The initial implementation of the package. + +# Versions +- [1.0.0 > Unreleased](https://github.com/ulrack/web/compare/1.0.0...HEAD) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6dcd974 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at info@grizzit.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7921a76 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +To contribute to this package, please keep to these guidelines. + +- Fork the package. +- Create a branch per feature. +- Commit your changes to these branches. +- Create a pull request per feature to the master branch of the original repository. + +## Pull requests + +Pull request should follow these rules, before they can get accepted. + +- Follow the [pull request template](PULL_REQUEST_TEMPLATE.md). +- Contains a short but complete description. +- Has passed all test command listed bellow. + +## Running Tests + +``` bash +$ vendor/bin/phpunit --coverage-text +$ vendor/bin/phpcs src/ tests/ +``` + +## Notes + +Multiple commits per feature are allowed, but please provide a good description in your pull request. +This description will be used to squash your feature into the master branch. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c2833e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) GrizzIT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ce6ccf9 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +# (bugfix / feature / refactor / etc.): Short descriptive title + +## Description + +Fixes # + +Changes proposed in this pull request: +- +- +- + +Make sure you can check the following boxes before submitting the pull request: +- [] The proposed changes work with the dependencies from composer. +- [] The proposed changes have been thoroughly tested. +- [] The proposed changes don't alter the code in such a manner that it conflicts with the initial purpose of the package. + +Optional checkbox +- [] Backwards incompatible diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a5d045 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +[![Build Status](https://travis-ci.com/ulrack/web.svg?branch=master)](https://travis-ci.com/ulrack/web) + +# Ulrack Web + +This package contains an implementation for routing web requests for PHP applications. + +## Installation + +To install the package run the following command: + +``` +composer require ulrack/web +``` + +## Usage + +Please see the [documentation](docs/index.md) for information on the usage of this package. + +## Change log + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. + +## MIT License + +Copyright (c) GrizzIT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3c4b4f4 --- /dev/null +++ b/composer.json @@ -0,0 +1,61 @@ +{ + "name": "ulrack/web", + "description": "The Ulrack web package.", + "keywords": [ + "framework", + "web", + "application" + ], + "type": "library", + "license": "MIT", + "prefer-stable": true, + "minimum-stability": "stable", + "require": { + "php": "^7.3", + "grizz-it/codec": "^1.0", + "grizz-it/configuration": "^1.1", + "grizz-it/enum": "^1.0", + "grizz-it/http": "^1.0", + "grizz-it/translator": "^1.1", + "ulrack/services": "^3.0" + }, + "authors": [ + { + "name": "Ulrack", + "homepage": "https://www.ulrack.com/", + "role": "Developer" + } + ], + "config": { + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Ulrack\\Web\\": "src/" + }, + "files": [ + "locator.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Ulrack\\Web\\Tests\\": "tests/" + } + }, + "archive": { + "exclude": [ + "/tests", + "/.gitignore", + "/.travis.yml", + "/phpunit.xml", + "/phpcs.xml", + "/PULL_REQUEST_TEMPLATE.md", + "/CODE_OF_CONDUCT.md", + "/CONTRIBUTING.md" + ] + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5" + } +} diff --git a/configuration/configuration/http-authorization.configuration.json b/configuration/configuration/http-authorization.configuration.json new file mode 100644 index 0000000..8af1c78 --- /dev/null +++ b/configuration/configuration/http-authorization.configuration.json @@ -0,0 +1,5 @@ +{ + "$schema": "configuration.schema.json", + "key": "http-authorization", + "location": "configuration/http-authorization" +} diff --git a/configuration/configuration/route-group.configuration.json b/configuration/configuration/route-group.configuration.json new file mode 100644 index 0000000..3dba2c4 --- /dev/null +++ b/configuration/configuration/route-group.configuration.json @@ -0,0 +1,5 @@ +{ + "$schema": "configuration.schema.json", + "key": "route-group", + "location": "configuration/route-group" +} diff --git a/configuration/configuration/route.configuration.json b/configuration/configuration/route.configuration.json new file mode 100644 index 0000000..0288494 --- /dev/null +++ b/configuration/configuration/route.configuration.json @@ -0,0 +1,5 @@ +{ + "$schema": "configuration.schema.json", + "key": "route", + "location": "configuration/route" +} diff --git a/configuration/configuration/web-mime-to-codec.configuration.json b/configuration/configuration/web-mime-to-codec.configuration.json new file mode 100644 index 0000000..edc4eb9 --- /dev/null +++ b/configuration/configuration/web-mime-to-codec.configuration.json @@ -0,0 +1,5 @@ +{ + "$schema": "configuration.schema.json", + "key": "web-mime-to-codec", + "location": "configuration/web-mime-to-codec" +} diff --git a/configuration/schema/http-authorization.schema.json b/configuration/schema/http-authorization.schema.json new file mode 100644 index 0000000..bba80f8 --- /dev/null +++ b/configuration/schema/http-authorization.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "http-authorization.schema.json", + "type": "object", + "properties": { + "key": { + "$ref": "#definitions/identifierDeclaration" + }, + "service": { + "type": "string" + } + }, + "required": ["key", "service"], + "definitions": { + "identifierDeclaration": { + "type": "string", + "pattern": "^[\\w-]+$" + } + } +} \ No newline at end of file diff --git a/configuration/schema/route-group.schema.json b/configuration/schema/route-group.schema.json new file mode 100644 index 0000000..caf8202 --- /dev/null +++ b/configuration/schema/route-group.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "route-group.schema.json", + "type": "object", + "properties": { + "key": { + "$ref": "#definitions/identifierDeclaration" + }, + "ports": { + "type": "array", + "items": { + "type": "integer" + } + }, + "hosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "authorizations": { + "type": "array", + "items": { + "$ref": "#definitions/identifierDeclaration" + } + }, + "route": { + "$ref": "#definitions/identifierDeclaration" + }, + "errorRegistryService": { + "type": "string" + }, + "weight": { + "type": "integer", + "default": 1000 + } + }, + "required": ["key", "ports", "hosts", "route", "errorRegistryService"], + "definitions": { + "identifierDeclaration": { + "type": "string", + "pattern": "^[\\w-]+$" + } + } +} diff --git a/configuration/schema/route.schema.json b/configuration/schema/route.schema.json new file mode 100644 index 0000000..22af5ff --- /dev/null +++ b/configuration/schema/route.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "route.schema.json", + "type": "object", + "properties": { + "key": { + "$ref": "#definitions/identifierDeclaration" + }, + "path": { + "type": "string", + "pattern": "^[\\w\\/{}\\.~-]*$" + }, + "service": { + "type": "string" + }, + "methods": { + "type": "array", + "items": { + "type": "string" + } + }, + "authorizations": { + "type": "array", + "items": { + "$ref": "#definitions/identifierDeclaration" + } + }, + "outputService": { + "type": "string" + }, + "errorRegistryService": { + "type": "string" + }, + "parent": { + "$ref": "#definitions/identifierDeclaration" + }, + "weight": { + "type": "integer", + "default": 1000 + } + }, + "required": ["path", "service", "key", "methods"], + "definitions": { + "identifierDeclaration": { + "type": "string", + "pattern": "^[\\w-]+$" + } + } +} diff --git a/configuration/web-mime-to-codec/json.translation.json b/configuration/web-mime-to-codec/json.translation.json new file mode 100644 index 0000000..410ab88 --- /dev/null +++ b/configuration/web-mime-to-codec/json.translation.json @@ -0,0 +1,9 @@ +{ + "$schema": "translation.schema.json", + "left": [ + "application\/json" + ], + "right": [ + "json" + ] +} \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ce2ebea --- /dev/null +++ b/docs/index.md @@ -0,0 +1,12 @@ +# Ulrack Web Documentation + +This documentation will provide you with information about working with, and extending this package. + +- [Main README](../README.md) +- [Usage](usage/index.md) + - [Request codecs](usage/request-codecs.md) + - [Route group](usage/route-group.md) + - [Route](usage/route.md) + - [Error](usage/error.md) + - [Authorization](usage/authorization.md) + - [Endpoint](usage/endpoint.md) diff --git a/docs/usage/authorization.md b/docs/usage/authorization.md new file mode 100644 index 0000000..0b7d565 --- /dev/null +++ b/docs/usage/authorization.md @@ -0,0 +1,25 @@ +# Ulrack Web - Authorization + +Authorizations are defined through services, should implement the +[AuthorizationInterface](../../src/Common/Request/AuthorizationInterface.php) +and can be registered on routes and route groups. They're used to determine +whether a request is authorized or not. These implementations would perform +checks on sessions, API keys and the like. + +The authorization needs to implement one method `isAllowed`. This will recieve +the same `$input` and `$output` as an endpoint. It must return `true` when the +authorization passes na `false` on failure. + +## Further reading + +[Back to usage index](index.md) + +[Request codecs](request-codecs.md) + +[Route group](route-group.md) + +[Route](route.md) + +[Error](error.md) + +[Endpoint](endpoint.md) diff --git a/docs/usage/endpoint.md b/docs/usage/endpoint.md new file mode 100644 index 0000000..676ead4 --- /dev/null +++ b/docs/usage/endpoint.md @@ -0,0 +1,53 @@ +# Ulrack Web - Endpoint + +Endpoints are defined as services and connected to routes. They perform the +actual logic for an endpoint. They must implement the +[EndpointInterface](../../src/Common/Endpoint/EndpointInterface.php). + +An implementation of an endpoint looks like the following: +```php +setContentType('application/json'); + $output->setOutput($output->getAcceptedContentTypes()); + } +} +``` + +The `$input` the information sent with the incoming request. +The `$output` contains a set of methods to manipulate the output for the +response. + +## Further reading + +[Back to usage index](index.md) + +[Request codecs](request-codecs.md) + +[Route group](route-group.md) + +[Route](route.md) + +[Error](error.md) + +[Authorization](authorization.md) diff --git a/docs/usage/error.md b/docs/usage/error.md new file mode 100644 index 0000000..4fb7441 --- /dev/null +++ b/docs/usage/error.md @@ -0,0 +1,38 @@ +# Ulrack Web - Error + +Errors are defined as services and are used to display the correct output per +error type. The must implement the +[ErrorInterface](../../src/Common/Error/ErrorInterface.php). The errors are +registered to an error registry. + +Two classes are provided by default which can be fully configured through +services. These are +[ConfigurableApiError](../../src/Component/Error/ConfigurableApiError.php) and +[ConfigurablePlainError](../../src/Component/Error/ConfigurablePlainError.php). + +A definition for an error looks like the following: +```json +{ + "web.errors.default.api.400": { + "class": "\\Ulrack\\Web\\Component\\Error\\ConfigurableApiError", + "parameters": { + "errorStatusCode": 400, + "errorMessage": "Bad Request" + } + } +} +``` + +## Further reading + +[Back to usage index](index.md) + +[Request codecs](request-codecs.md) + +[Route group](route-group.md) + +[Route](route.md) + +[Authorization](authorization.md) + +[Endpoint](endpoint.md) diff --git a/docs/usage/index.md b/docs/usage/index.md new file mode 100644 index 0000000..81ec5a0 --- /dev/null +++ b/docs/usage/index.md @@ -0,0 +1,13 @@ +# Ulrack Web - Usage Documentation + +This part of the documentation will focus on the usage of this package. + +## Index + +- [Main index](../index.md) +- [Request codecs](request-codecs.md) +- [Route group](route-group.md) +- [Route](route.md) +- [Error](error.md) +- [Authorization](authorization.md) +- [Endpoint](endpoint.md) \ No newline at end of file diff --git a/docs/usage/request-codecs.md b/docs/usage/request-codecs.md new file mode 100644 index 0000000..2cf0c96 --- /dev/null +++ b/docs/usage/request-codecs.md @@ -0,0 +1,20 @@ +# Ulrack Web - Request codecs + +By default, Ulrack implements a set of codec for configurations. Due to the +nature of YAML, it is insecure by default, because it allows statements that +can be evaluated. Due to this reason, codecs for the web package are separately +configured. By default only the JSON codec is defined. + +## Further reading + +[Back to usage index](index.md) + +[Route group](route-group.md) + +[Route](route.md) + +[Error](error.md) + +[Authorization](authorization.md) + +[Endpoint](endpoint.md) diff --git a/docs/usage/route-group.md b/docs/usage/route-group.md new file mode 100644 index 0000000..da93366 --- /dev/null +++ b/docs/usage/route-group.md @@ -0,0 +1,43 @@ +# Ulrack Web - Route group + +A route group defines the initial connection to the application. +It is described by the `route-group.schema.json` schema. + +An example definition looks like the following: +```json +{ + "$schema": "route-group.schema.json", + "key": "main", + "ports": [ + 80, + 443 + ], + "hosts": [ + "*.example.com*" + ], + "route": "main-home", + "errorRegistryService": "services.web.errors.default.api.registry" +} +``` + +The `ports` node defines which ports are open to the route group. The `hosts` +node defines which URL's should be routed to the route group. The `route` node +defines the default route of the route group. The `errorRegistryService` node +defines which service is used to retrieve the error registry. The optional +`authorizations` node defines a list of authorization services used to verify +the authority of the request. The optional `weight` node defines the order in +which route groups are routed. + +## Further reading + +[Back to usage index](index.md) + +[Request codecs](request-codecs.md) + +[Route](route.md) + +[Error](error.md) + +[Authorization](authorization.md) + +[Endpoint](endpoint.md) diff --git a/docs/usage/route.md b/docs/usage/route.md new file mode 100644 index 0000000..4783fb8 --- /dev/null +++ b/docs/usage/route.md @@ -0,0 +1,46 @@ +# Ulrack Web - Route group + +A route defines the path of an endpoint and sub-routes. +It is described by the `route.schema.json` schema. + +An example definition looks like the following: +```json +{ + "$schema": "route.schema.json", + "key": "main-home", + "path": "/", + "service": "services.main-home-endpoint", + "methods": [ + "GET" + ], + "outputService": "services.web.handler.output" +} +``` + +The `key` node defines the reference of the route. The `path` node defines the +path to the endpoint. The `service` node defines the service which is +constructed and is expected to return an implementation of the +[EndpointInterface](../../src/Common/Endpoint/EndpointInterface.php). The +`methods` node defines which methods are accepted for the endpoint. The +`outputService` defines which service is used to handle the output of the +endpoint. This output service is inherited by the children of the route if it +is not defined. The optional `authorizations` node defines a list of +authorization services used to verify the authority of the request. The optional +`errorRegistryService` node defines the error registry service for the route. If +it is not defined, the service from the route group is used. The optional +`parent` defines the parent route of the defined route. When a route is +classified as a sub-route it will extend its' parent `path`. + +## Further reading + +[Back to usage index](index.md) + +[Request codecs](request-codecs.md) + +[Route group](route-group.md) + +[Error](error.md) + +[Authorization](authorization.md) + +[Endpoint](endpoint.md) diff --git a/locator.php b/locator.php new file mode 100644 index 0000000..01c5f58 --- /dev/null +++ b/locator.php @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..2b83b56 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,16 @@ + + + + + src + + + + + tests + + + diff --git a/src/Common/Endpoint/EndpointInterface.php b/src/Common/Endpoint/EndpointInterface.php new file mode 100644 index 0000000..bb2ead1 --- /dev/null +++ b/src/Common/Endpoint/EndpointInterface.php @@ -0,0 +1,24 @@ +request = $request; + } + + /** + * Retrieves the request object. + * + * @return RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } + + /** + * Checks whether a parameter is set. + * + * @param string $key + * + * @return bool + */ + public function hasParameter(string $key): bool + { + return isset($this->parameters[$key]); + } + + /** + * Retrieves a parameter based on the key. + * + * @param string $key + * + * @return mixed + */ + public function getParameter(string $key) + { + return $this->parameters[$key]; + } + + /** + * Sets a parameter. + * + * @param string $key + * @param mixed $value + * + * @return void + */ + public function setParameter(string $key, $value): void + { + $this->parameters[$key] = $value; + } + + /** + * Retrieves a list of keys of available parameters. + * + * @return string[] + */ + public function getParameterKeys(): array + { + return array_keys($this->parameters); + } +} diff --git a/src/Component/Endpoint/Output.php b/src/Component/Endpoint/Output.php new file mode 100644 index 0000000..55c482f --- /dev/null +++ b/src/Component/Endpoint/Output.php @@ -0,0 +1,267 @@ +acceptedContentTypes = $acceptedContentTypes; + if (count($acceptedContentTypes) > 0) { + $this->contentType = $acceptedContentTypes[0]; + } + } + + /** + * Retrieves the output. + * + * @return mixed + */ + public function getOutput() + { + return $this->output; + } + + /** + * Sets the value of the output. + * + * @param mixed $output + * + * @return void + */ + public function setOutput($output): void + { + $this->output = $output; + } + + /** + * Sets the serving protocol. + * + * @param string $protocol + * + * @return void + */ + public function setProtocol(string $protocol): void + { + $this->protocol = $protocol; + } + + /** + * Retrieves current serving protocol. + * + * @return string + */ + public function getProtocol(): string + { + return $this->protocol; + } + + /** + * Sets the status code for the output. + * + * @param int $code + * + * @return void + */ + public function setStatusCode(int $code): void + { + $this->statusCode = $code; + } + + /** + * Retrieves the current status code. + * + * @return int + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * Retrieves a list of header keys. + * + * @return string[] + */ + public function getHeaderKeys(): array + { + return array_keys($this->headers); + } + + /** + * Retrieves the header. + * + * @param string $key + * + * @return string + */ + public function getHeader(string $key): string + { + return $this->headers[$key]; + } + + /** + * Sets a header for the response. + * + * @param string $key + * @param string|null $value + * + * @return void + */ + public function setHeader(string $key, ?string $value): void + { + $this->headers[$key] = $value; + } + + /** + * Checks whether a header is set for the output. + * + * @param string $key + * + * @return bool + */ + public function hasHeader(string $key): bool + { + return isset($this->headers[$key]); + } + + /** + * Retrieves a list of accepted content types for the response. + * + * @return string[] + */ + public function getAcceptedContentTypes(): array + { + return $this->acceptedContentTypes; + } + + /** + * Retrieves the content type for the output. + * + * @return string + */ + public function getContentType(): string + { + return $this->contentType; + } + + /** + * Sets the content type for the output. + * + * @param string $mimeType + * + * @return void + */ + public function setContentType(string $mimeType): void + { + $this->contentType = $mimeType; + } + + /** + * Checks whether a parameter is set. + * + * @param string $key + * + * @return bool + */ + public function hasParameter(string $key): bool + { + return isset($this->parameters[$key]); + } + + /** + * Retrieves a parameter based on the key. + * + * @param string $key + * + * @return mixed + */ + public function getParameter(string $key) + { + return $this->parameters[$key]; + } + + /** + * Sets a parameter. + * + * @param string $key + * @param mixed $value + * + * @return void + */ + public function setParameter(string $key, $value): void + { + $this->parameters[$key] = $value; + } + + /** + * Retrieves a list of keys of available parameters. + * + * @return string[] + */ + public function getParameterKeys(): array + { + return array_keys($this->parameters); + } +} diff --git a/src/Component/Error/ConfigurableApiError.php b/src/Component/Error/ConfigurableApiError.php new file mode 100644 index 0000000..ec1fa40 --- /dev/null +++ b/src/Component/Error/ConfigurableApiError.php @@ -0,0 +1,88 @@ +errorStatusCode = $errorStatusCode; + $this->errorCode = $errorCode; + $this->errorMessage = $errorMessage; + $this->defaultContentType = $defaultContentType; + } + + /** + * Sets the input and output up for an error response. + * + * @param InputInterface $input + * @param OutputInterface $output + * + * @return void + */ + public function __invoke( + InputInterface $input, + OutputInterface $output + ): void { + $output->setStatusCode($this->errorStatusCode); + $output->setOutput([ + 'message' => $this->errorMessage + ?? StatusCodeEnum::getOptions()['CODE_' . $this->errorStatusCode], + 'error_code' => $this->errorCode ?? $this->errorStatusCode + ]); + + if ($this->errorStatusCode === 406) { + $output->setContentType($this->defaultContentType); + } + } +} diff --git a/src/Component/Error/ConfigurablePlainError.php b/src/Component/Error/ConfigurablePlainError.php new file mode 100644 index 0000000..b3d1c4a --- /dev/null +++ b/src/Component/Error/ConfigurablePlainError.php @@ -0,0 +1,81 @@ +errorStatusCode = $errorStatusCode; + $this->errorCode = $errorCode; + $this->errorMessage = $errorMessage; + } + + /** + * Sets the input and output up for an error response. + * + * @param InputInterface $input + * @param OutputInterface $output + * + * @return void + */ + public function __invoke( + InputInterface $input, + OutputInterface $output + ): void { + $output->setStatusCode($this->errorStatusCode); + $output->setOutput( + sprintf( + '%s: %s', + $this->errorCode ?? $this->errorStatusCode, + $this->errorMessage + ?? StatusCodeEnum::getOptions()[ + 'CODE_' . $this->errorStatusCode + ] + ) + ); + $output->setContentType('text/plain'); + } +} diff --git a/src/Component/Error/ErrorHandler.php b/src/Component/Error/ErrorHandler.php new file mode 100644 index 0000000..647fc38 --- /dev/null +++ b/src/Component/Error/ErrorHandler.php @@ -0,0 +1,131 @@ +input = $input; + $this->output = $output; + $this->outputHandler = $outputHandler; + $this->errorRegistry = $errorRegistry; + } + + /** + * Sets the output handler used in the error handler. + * + * @param OutputHandlerInterface $outputHandler + * + * @return void + */ + public function setOutputHandler( + OutputHandlerInterface $outputHandler + ): void { + $this->outputHandler = $outputHandler; + } + + /** + * Sets the error registry used in the error handling. + * + * @param ErrorRegistryInterface $errorRegistry + * + * @return void + */ + public function setErrorRegistry( + ErrorRegistryInterface $errorRegistry + ): void { + $this->errorRegistry = $errorRegistry; + } + + /** + * Output the error based on an exception. + * + * @param Throwable $exception + * + * @return void + */ + public function outputByException(Throwable $exception): void + { + $code = 500; + + if ($exception instanceof HttpException) { + $code = $exception->getErrorCode(); + } + + $this->input->setParameter('exception', $exception); + $this->outputByCode($code); + } + + /** + * Output the error based on a code. + * + * @param int $code + * + * @return void + */ + public function outputByCode(int $code): void + { + try { + $error = $this->errorRegistry->getError($code); + } catch (Throwable $exception) { + $error = $this->errorRegistry->getError(500); + } + + $error($this->input, $this->output); + $this->outputHandler->__invoke($this->input, $this->output); + } +} diff --git a/src/Component/Error/ErrorRegistry.php b/src/Component/Error/ErrorRegistry.php new file mode 100644 index 0000000..0773c04 --- /dev/null +++ b/src/Component/Error/ErrorRegistry.php @@ -0,0 +1,68 @@ +errors = $errors; + } + + /** + * Sets up an error for a code. + * + * @param int $code + * @param ErrorInterface $error + * + * @return void + */ + public function setError(int $code, ErrorInterface $error): void + { + $this->errors[$code] = $error; + } + + /** + * Retrieves the error by the response code. + * + * @param int $code + * + * @return ErrorInterface + */ + public function getError(int $code): ErrorInterface + { + return $this->errors[$code]; + } + + /** + * Checks whether the error is configured. + * + * @param int $code + * + * @return bool + */ + public function hasError(int $code): bool + { + return isset($this->errors[$code]); + } +} diff --git a/src/Component/Output/CodecOutputConverter.php b/src/Component/Output/CodecOutputConverter.php new file mode 100644 index 0000000..ce99e7b --- /dev/null +++ b/src/Component/Output/CodecOutputConverter.php @@ -0,0 +1,65 @@ +mimeToCodec = $mimeToCodec; + $this->codecRegistry = $codecRegistry; + } + + /** + * Converts the registered output to a string. + * + * @param OutputInterface $output + * + * @return string|null + */ + public function __invoke(OutputInterface $output): ?string + { + try { + return $this->codecRegistry->getEncoder( + $this->mimeToCodec->getRight( + $output->getContentType() + ) + )->encode($output->getOutput()); + } catch (Throwable $exception) { + return null; + } + } +} diff --git a/src/Component/Output/HeaderHandler.php b/src/Component/Output/HeaderHandler.php new file mode 100644 index 0000000..b0115a9 --- /dev/null +++ b/src/Component/Output/HeaderHandler.php @@ -0,0 +1,55 @@ +getStatusCode(); + + header(sprintf( + '%s %d %s', + $input->getRequest()->getServerVariable('SERVER_PROTOCOL'), + $statusCode, + StatusCodeEnum::getOptions()['CODE_' . $statusCode] + )); + + foreach ($output->getHeaderKeys() as $headerKey) { + if ( + $output->hasHeader($headerKey) && + $headerKey !== 'Content-Type' + ) { + header( + sprintf( + '%s: %s', + $headerKey, + $output->getHeader($headerKey) + ) + ); + } + } + + header('Content-Type: ' . $output->getContentType()); + } +} diff --git a/src/Component/Output/OutputHandler.php b/src/Component/Output/OutputHandler.php new file mode 100644 index 0000000..e038ff1 --- /dev/null +++ b/src/Component/Output/OutputHandler.php @@ -0,0 +1,72 @@ +headerHandler = $headerHandler; + $this->outputConverters = $outputConverters; + } + + /** + * Handles the conversion of the internal output to visible output. + * + * @param InputInterface $input + * @param OutputInterface $output + * + * @return void + * + * @throws NotAcceptedException When there is no converter for the output type. + */ + public function __invoke( + InputInterface $input, + OutputInterface $output + ): void { + foreach ($this->outputConverters as $outputConverter) { + $outputString = $outputConverter($output); + if ($outputString !== null) { + $this->headerHandler->__invoke($input, $output); + echo $outputString; + return; + } + } + + throw new NotAcceptedException('Can not fulfill accepted output.'); + } +} diff --git a/src/Component/Output/PlainOutputConverter.php b/src/Component/Output/PlainOutputConverter.php new file mode 100644 index 0000000..4514056 --- /dev/null +++ b/src/Component/Output/PlainOutputConverter.php @@ -0,0 +1,61 @@ +contentTypes = $contentTypes; + } + + /** + * Converts the registered output to a string. + * + * @param OutputInterface $output + * + * @return string|null + */ + public function __invoke(OutputInterface $output): ?string + { + $outputContent = $output->getOutput(); + + if (in_array($output->getContentType(), $this->contentTypes)) { + if (is_string($outputContent)) { + return $outputContent; + } elseif ( + is_array($outputContent) && + isset($outputContent['message'], $outputContent['error_code']) + ) { + return sprintf( + '%s: %s', + $outputContent['error_code'], + $outputContent['message'] + ); + } + } + + return null; + } +} diff --git a/src/Component/Registry/AuthorizationRegistry.php b/src/Component/Registry/AuthorizationRegistry.php new file mode 100644 index 0000000..ecfbfd8 --- /dev/null +++ b/src/Component/Registry/AuthorizationRegistry.php @@ -0,0 +1,77 @@ +authorizations = $authorizations; + } + + /** + * Registers an authorization. + * + * @param string $key + * @param string $serviceKey + * + * @return void + */ + public function register(string $key, string $serviceKey): void + { + $this->authorizations[$key] = $serviceKey; + } + + /** + * Checks whether the authorization service is registered. + * + * @param string $key + * + * @return bool + */ + public function has(string $key): bool + { + return isset($this->authorizations[$key]); + } + + /** + * Retrieves the service key by the registration key. + * + * @param string $key + * + * @return string + */ + public function get(string $key): string + { + return $this->authorizations[$key]; + } + + /** + * Exports all registered authorizations to an array. + * + * @return array + */ + public function toArray(): array + { + return $this->authorizations; + } +} diff --git a/src/Component/Registry/RouteGroupRegistry.php b/src/Component/Registry/RouteGroupRegistry.php new file mode 100644 index 0000000..70f0f88 --- /dev/null +++ b/src/Component/Registry/RouteGroupRegistry.php @@ -0,0 +1,80 @@ +routeGroups = $routeGroups; + } + + /** + * Registers a route group. + * + * @param string $key + * @param RouteGroupInterface $routeGroup + * + * @return void + */ + public function register( + string $key, + RouteGroupInterface $routeGroup + ): void { + $this->routeGroups[$key] = $routeGroup; + } + + /** + * Checks whether the route group is registered. + * + * @param string $key + * + * @return bool + */ + public function has(string $key): bool + { + return isset($this->routeGroups[$key]); + } + + /** + * Retrieves the route group by a key. + * + * @param string $key + * + * @return RouteGroupInterface + */ + public function get(string $key): RouteGroupInterface + { + return $this->routeGroups[$key]; + } + + /** + * Retrieves the registered keys. + * + * @return string[] + */ + public function getKeys(): array + { + return array_keys($this->routeGroups); + } +} diff --git a/src/Component/Request/AuthorizationHandler.php b/src/Component/Request/AuthorizationHandler.php new file mode 100644 index 0000000..0bba782 --- /dev/null +++ b/src/Component/Request/AuthorizationHandler.php @@ -0,0 +1,104 @@ +serviceFactory = $serviceFactory; + $this->registry = $registry; + } + + /** + * Checks whether the authorization passes. + * + * @param string[] $authorizationKeys + * @param InputInterface $input + * @param OutputInterface $output + * + * @return bool + */ + public function pass( + array $authorizationKeys, + InputInterface $input, + OutputInterface $output + ): bool { + foreach ($authorizationKeys as $authorizationKey) { + if ( + !$this->getAuthorizationByKey($authorizationKey) + ->isAllowed($input, $output) + ) { + return false; + } + } + + return true; + } + + /** + * Retrieves the authorization instance by the configured key. + * + * @return AuthorizationInterface + * + * @throws UnauthorizedException When the authorization could not be found. + */ + private function getAuthorizationByKey(string $key): AuthorizationInterface + { + if (!isset($this->authorizers[$key])) { + if (!$this->registry->has($key)) { + throw new UnauthorizedException( + 'Misconfigured authorization detected' + ); + } + + $this->authorizers[$key] = $this->serviceFactory->create( + $this->registry->get($key) + ); + } + + return $this->authorizers[$key]; + } +} diff --git a/src/Component/Router/GroupRouter.php b/src/Component/Router/GroupRouter.php new file mode 100644 index 0000000..7475262 --- /dev/null +++ b/src/Component/Router/GroupRouter.php @@ -0,0 +1,147 @@ +errorHandler = $errorHandler; + $this->outputHandler = $outputHandler; + $this->serviceFactory = $serviceFactory; + $this->authorizationHandler = $authorizationHandler; + $this->pathMatcher = $pathMatcher; + $this->routeGroups = $routeGroups; + } + + /** + * Resolves the request to an endpoint, executes it and renders the response. + * + * @param InputInterface $input + * @param OutputInterface $output + * + * @return void + */ + public function __invoke( + InputInterface $input, + OutputInterface $output + ): void { + $this->errorHandler->setOutputHandler($this->outputHandler); + $uri = $input->getRequest()->getUri(); + foreach ($this->routeGroups as $routeGroup) { + if (!in_array($uri->getPort(), $routeGroup->getPorts())) { + continue; + } + + foreach ($routeGroup->getHosts() as $host) { + if (fnmatch($host, $uri->getHost())) { + $this->errorHandler->setErrorRegistry( + $this->serviceFactory->create( + $routeGroup->getErrorRegistryService() + ) + ); + + if ( + !$this->authorizationHandler->pass( + $routeGroup->getAuthorizations(), + $input, + $output + ) + ) { + throw new UnauthorizedException('Unauthorized'); + } + + (new Router( + $this->errorHandler, + $this->outputHandler, + $this->serviceFactory, + $this->authorizationHandler, + $routeGroup->getRoute(), + $this->pathMatcher + ))($input, $output); + + return; + } + } + + continue; + } + + throw new NotFoundException('Domain could not be resolved.'); + } +} diff --git a/src/Component/Router/PathMatcher.php b/src/Component/Router/PathMatcher.php new file mode 100644 index 0000000..6c21fd8 --- /dev/null +++ b/src/Component/Router/PathMatcher.php @@ -0,0 +1,95 @@ +getPath(), '/'); + + if ($routePath === '') { + return [ + 'path' => $path, + 'parameters' => [] + ]; + } + + $matchParam = preg_match( + '/{[^\/]+}/', + $routePath + ); + + if (strpos($path, $routePath) === 0 && !$matchParam) { + return [ + 'path' => $this->stripPath($path, $routePath), + 'parameters' => [] + ]; + } + + if (!$matchParam) { + return null; + } + + $replaces = []; + $newParams = []; + $pathSlugs = explode('/', $path); + foreach (explode('/', $routePath) as $key => $slug) { + if (!isset($pathSlugs[$key])) { + return null; + } + + $replace = trim($slug, '{}'); + if ($replace === $slug) { + if ($pathSlugs[$key] === $slug) { + $replaces[] = $slug; + continue; + } + + return null; + } + + $newParams[$replace] = $pathSlugs[$key]; + $replaces[] = $pathSlugs[$key]; + } + + return [ + 'path' => $this->stripPath($path, implode('/', $replaces)), + 'parameters' => $newParams + ]; + } + + /** + * Strips a part of the path. + * + * @param string $path + * @param string $strip + * + * @return string + */ + private function stripPath(string $path, string $strip): string + { + return preg_replace(sprintf( + '/^%s/', + preg_quote($strip . '/', '/') + ), '', $path, 1); + } +} diff --git a/src/Component/Router/Route.php b/src/Component/Router/Route.php new file mode 100644 index 0000000..c9a8643 --- /dev/null +++ b/src/Component/Router/Route.php @@ -0,0 +1,182 @@ +path = $path; + $this->service = $service; + $this->methods = $methods; + $this->outputService = $outputService; + $this->errorRegistryService = $errorRegistryService; + $this->routes = $routes; + } + + /** + * Retrieves the configured path. + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Retrieves the configured service. + * + * @return string + */ + public function getService(): string + { + return $this->service; + } + + /** + * Retrieves the sub-routes. + * + * @return RouteInterface[] + */ + public function getRoutes(): array + { + return $this->routes; + } + + /** + * Adds a sub-route to the route. + * + * @param RouteInterface $route + * + * @return void + */ + public function addRoute(RouteInterface $route): void + { + $this->routes[] = $route; + } + + /** + * Retrieves the output handler service key. + * + * @return string|null + */ + public function getOutputHandlerService(): ?string + { + return $this->outputService; + } + + /** + * Retrieves the allowed methods for the route. + * + * @return string[] + */ + public function getMethods(): array + { + return $this->methods; + } + + /** + * Retrieves the error registry service. + * + * @return string|null + */ + public function getErrorRegistryService(): ?string + { + return $this->errorRegistryService; + } + + /** + * Adds authorization to the route. + * + * @param string $key + * + * @return void + */ + public function addAuthorization(string $key): void + { + $this->authorization[] = $key; + } + + /** + * Returns a list of authorization services. + * + * @return array + */ + public function getAuthorizations(): array + { + return $this->authorization; + } +} diff --git a/src/Component/Router/RouteGroup.php b/src/Component/Router/RouteGroup.php new file mode 100644 index 0000000..9986317 --- /dev/null +++ b/src/Component/Router/RouteGroup.php @@ -0,0 +1,131 @@ +ports = $ports; + $this->hosts = $hosts; + $this->route = $route; + $this->errorRegistryService = $errorRegistryService; + } + + /** + * Retrieves the configured ports. + * + * @return int[] + */ + public function getPorts(): array + { + return $this->ports; + } + + /** + * Retrieves the configured hosts. + * + * @return string[] + */ + public function getHosts(): array + { + return $this->hosts; + } + + /** + * Retrieves the home route. + * + * @return RouteInterface + */ + public function getRoute(): RouteInterface + { + return $this->route; + } + + /** + * Retrieves the error registry service. + * + * @return string + */ + public function getErrorRegistryService(): string + { + return $this->errorRegistryService; + } + + /** + * Adds authorization to the route. + * + * @param string $key + * + * @return void + */ + public function addAuthorization(string $key): void + { + $this->authorization[] = $key; + } + + /** + * Returns a list of authorization services. + * + * @return array + */ + public function getAuthorizations(): array + { + return $this->authorization; + } +} diff --git a/src/Component/Router/Router.php b/src/Component/Router/Router.php new file mode 100644 index 0000000..2459a01 --- /dev/null +++ b/src/Component/Router/Router.php @@ -0,0 +1,269 @@ +errorHandler = $errorHandler; + $this->outputHandler = $outputHandler; + $this->serviceFactory = $serviceFactory; + $this->authorizationHandler = $authorizationHandler; + $this->route = $route; + $this->pathMatcher = $pathMatcher; + } + + /** + * Resolves the request to an endpoint, executes it and renders the response. + * + * @param InputInterface $input + * @param OutputInterface $output + * + * @return void + * + * @throws NotFoundException When none of the endpoints matches the request. + */ + public function __invoke( + InputInterface $input, + OutputInterface $output + ): void { + if ( + $this->executeRoute( + $this->route, + $input, + $output, + sprintf( + '%s/', + trim($input->getRequest()->getUri()->getPath(), '/') + ) + ) === false + ) { + throw new NotFoundException('Endpoint could not be resolved.'); + } + } + + /** + * Determines whether the route can be executed and then executes it. + * + * @param RouteInterface $route + * @param InputInterface $input + * @param OutputInterface $output + * @param string $normalizedPath + * @param string $outputHandler + * @param string $errorRegistry + * @param array $parameters + * + * @return bool + */ + private function executeRoute( + RouteInterface $route, + InputInterface $input, + OutputInterface $output, + string $normalizedPath, + string $outputHandler = '', + string $errorRegistry = '', + array $parameters = [] + ): bool { + $matchInfo = $this->pathMatcher->__invoke($route, $normalizedPath); + if ($matchInfo === null) { + return false; + } + + $normalizedPath = $matchInfo['path']; + $parameters = array_merge($parameters, $matchInfo['parameters']); + + $outputHandler = $route->getOutputHandlerService() + ?? $outputHandler; + + $errorRegistry = $route->getErrorRegistryService() + ?? $errorRegistry; + + if ($normalizedPath === '') { + $this->setParameters($parameters, $input); + + $outputHandlerService = $this->outputHandler; + if ($outputHandler !== '') { + /** @var OutputHandlerInterface $outputHandlerService */ + $outputHandlerService = $this->serviceFactory->create( + $outputHandler + ); + + $this->errorHandler->setOutputHandler($outputHandlerService); + } + + if ($errorRegistry !== '') { + $this->errorHandler->setErrorRegistry( + $this->serviceFactory->create( + $errorRegistry + ) + ); + } + + $this->preExecutionChecks($input, $output, $route); + + /** @var EndpointInterface $endpoint */ + $endpoint = $this->serviceFactory->create($route->getService()); + + if (!is_a($endpoint, EndpointInterface::class)) { + throw new InvalidArgumentException( + sprintf( + 'Service %s must be of type %s', + $route->getService(), + EndpointInterface::class + ) + ); + } + + $endpoint($input, $output); + $outputHandlerService($input, $output); + + return true; + } + + foreach ($route->getRoutes() as $subRoute) { + $result = $this->executeRoute( + $subRoute, + $input, + $output, + $normalizedPath, + $outputHandler, + $errorRegistry, + $parameters + ); + } + + return $result ?? false; + } + + /** + * Sets the parameters on the input. + * + * @param array $parameters + * @param InputInterface $input + * + * @return void + */ + private function setParameters( + array $parameters, + InputInterface $input + ): void { + foreach ($parameters as $key => $value) { + $input->setParameter($key, $value); + } + } + + /** + * Executes the pre-execution checks and throws an error if something is incorrect. + * + * @param InputInterface $input + * @param RouteInterface $route + * + * @return void + * + * @throws MethodNotAllowedException When the method is not allowed. + * @throws UnauthorizedException When the authorization fails. + */ + private function preExecutionChecks( + InputInterface $input, + OutputInterface $output, + RouteInterface $route + ): void { + if ( + !in_array( + $input->getRequest()->getMethod(), + $route->getMethods() + ) + ) { + throw new MethodNotAllowedException('Method Not Allowed'); + } + + if ( + !$this->authorizationHandler->pass( + $route->getAuthorizations(), + $input, + $output + ) + ) { + throw new UnauthorizedException('Unauthorized'); + } + } +} diff --git a/src/Exception/HttpException.php b/src/Exception/HttpException.php new file mode 100644 index 0000000..09d2c93 --- /dev/null +++ b/src/Exception/HttpException.php @@ -0,0 +1,49 @@ +errorCode = $errorCode; + parent::__construct($message, $code, $previous); + } + + /** + * Retrieves the HTTP specific error code. + * + * @return int + */ + public function getErrorCode(): int + { + return $this->errorCode; + } +} diff --git a/src/Exception/MethodNotAllowedException.php b/src/Exception/MethodNotAllowedException.php new file mode 100644 index 0000000..bdb2c2e --- /dev/null +++ b/src/Exception/MethodNotAllowedException.php @@ -0,0 +1,28 @@ +mimeToCodec = $mimeToCodec; + $this->codecRegistry = $codecRegistry; + $this->uriFactory = $uriFactory ?? new UriFactory(); + $this->inputStream = $inputStream; + } + + /** + * Creates the input. + * + * @param array $server + * @param array $get + * @param array $post + * @param array $files + * @param array $cookies + * + * @return InputInterface + * + * @throws UnsupportedMediaTypeException When the request can not be created. + */ + public function create( + array $server = [], + array $get = [], + array $post = [], + array $files = [], + array $cookies = [] + ): InputInterface { + $fileManager = new UploadedFileManager($files); + $parsedUri = parse_url($server['REQUEST_URI']); + $uri = $this->uriFactory->create( + $server['REQUEST_SCHEME'] ?? '', + $server['PHP_AUTH_USER'] ?? '', + $server['PHP_AUTH_PASS'] ?? '', + $server['HTTP_HOST'] ?? '', + $server['SERVER_PORT'] ?? -1, + $parsedUri['path'] ?? '', + $get, + $parsedUri['fragment'] ?? '' + ); + + $cookieManager = new CookieManager($cookies); + + $payload = !empty($post) ? $post : null; + + if ( + !empty($server['CONTENT_TYPE']) && + $server['CONTENT_TYPE'] !== 'application/x-www-form-urlencoded' && + strpos($server['CONTENT_TYPE'], 'multipart/form-data') === false + ) { + try { + $payload = $this->codecRegistry->getDecoder( + $this->mimeToCodec->getRight($server['CONTENT_TYPE']) + )->decode(file_get_contents($this->inputStream)); + } catch (Throwable $exception) { + throw new UnsupportedMediaTypeException( + 'Can not decode input.', + 0, + $exception + ); + } + } + + return new Input( + new Request( + $uri, + $cookieManager, + $fileManager, + $payload, + $server['SERVER_PROTOCOL'] ?? '', + $server['REQUEST_METHOD'] ?? '', + $this->getAllHeaders($server), + $server + ) + ); + } + + /** + * Extracts all HTTP headers from the incoming request. + * + * @param array $server + * + * @return array + */ + private function getAllHeaders(array $server): array + { + $headers = []; + foreach ($server as $name => $value) { + if (substr($name, 0, 5) == 'HTTP_') { + $headers[str_replace( + ' ', + '-', + ucwords( + strtolower( + str_replace( + '_', + ' ', + substr($name, 5) + ) + ) + ) + )] = $value; + } + } + + return $headers; + } +} diff --git a/src/Factory/OutputFactory.php b/src/Factory/OutputFactory.php new file mode 100644 index 0000000..823a1bf --- /dev/null +++ b/src/Factory/OutputFactory.php @@ -0,0 +1,71 @@ +getRequest(); + $output = $request->hasHeader('Accept') ? new Output( + ...$this->parseAcceptHeader( + $request->getHeader('Accept') + ) + ) : new Output('*/*'); + + $output->setProtocol($request->getProtocol()); + + return $output; + } + + /** + * Parses and orders the accept headers. + * + * @param string $acceptHeader + * + * @return string[] + */ + private function parseAcceptHeader(string $acceptHeader): array + { + $accepts = explode(',', $acceptHeader); + $returnOrder = []; + foreach ($accepts as $accept) { + $acceptQuality = explode(';', $accept); + $quality = 1000; + if (count($acceptQuality) > 1) { + if ( + preg_match( + '/q=(\d\.?\d*)/', + $acceptQuality[1], + $qualityPart + ) === 1 + ) { + $quality = (int) $qualityPart[1] * 1000; + } + } + + $returnOrder[$quality][] = $acceptQuality[0]; + } + + krsort($returnOrder); + + return array_merge(...$returnOrder); + } +} diff --git a/tests/Component/Endpoint/InputTest.php b/tests/Component/Endpoint/InputTest.php new file mode 100644 index 0000000..37b265a --- /dev/null +++ b/tests/Component/Endpoint/InputTest.php @@ -0,0 +1,55 @@ +createMock(RequestInterface::class); + $subject = new Input($request); + + $this->assertEquals($request, $subject->getRequest()); + } + + /** + * @covers ::hasParameter + * @covers ::getParameter + * @covers ::setParameter + * @covers ::getParameterKeys + * @covers ::__construct + * + * @return void + */ + public function testParameters(): void + { + $request = $this->createMock(RequestInterface::class); + $subject = new Input($request); + $key = 'foo'; + $value = 'bar'; + + $this->assertEquals(false, $subject->hasParameter($key)); + $subject->setParameter($key, $value); + $this->assertEquals(true, $subject->hasParameter($key)); + $this->assertEquals($value, $subject->getParameter($key)); + $this->assertEquals(['foo'], $subject->getParameterKeys()); + } +} diff --git a/tests/Component/Endpoint/OutputTest.php b/tests/Component/Endpoint/OutputTest.php new file mode 100644 index 0000000..ff1e893 --- /dev/null +++ b/tests/Component/Endpoint/OutputTest.php @@ -0,0 +1,73 @@ +setOutput('foo'); + $this->assertEquals('foo', $subject->getOutput()); + + $subject->setProtocol('HTTP/2.0'); + $this->assertEquals('HTTP/2.0', $subject->getProtocol()); + + $subject->setStatusCode(200); + $this->assertEquals(200, $subject->getStatusCode()); + + $subject->setHeader('Content-Language', 'en'); + $this->assertEquals(true, $subject->hasHeader('Content-Language')); + $this->assertEquals(['Content-Language'], $subject->getHeaderKeys()); + $this->assertEquals('en', $subject->getHeader('Content-Language')); + + $this->assertEquals( + ['application/json', 'text/html'], + $subject->getAcceptedContentTypes() + ); + + $this->assertEquals('application/json', $subject->getContentType()); + $subject->setContentType('text/html'); + $this->assertEquals('text/html', $subject->getContentType()); + + $this->assertEquals([], $subject->getParameterKeys()); + $subject->setParameter('foo', 'bar'); + $this->assertEquals(['foo'], $subject->getParameterKeys()); + $this->assertEquals(true, $subject->hasParameter('foo')); + $this->assertEquals('bar', $subject->getParameter('foo')); + } +} diff --git a/tests/Component/Error/ConfigurableApiErrorTest.php b/tests/Component/Error/ConfigurableApiErrorTest.php new file mode 100644 index 0000000..9fd3b49 --- /dev/null +++ b/tests/Component/Error/ConfigurableApiErrorTest.php @@ -0,0 +1,53 @@ +createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $output->expects(static::once()) + ->method('setStatusCode') + ->with($errorStatusCode); + + $output->expects(static::once()) + ->method('setOutput') + ->with( + [ + 'message' => 'Not Acceptable', + 'error_code' => $errorStatusCode + ] + ); + + $output->expects(static::once()) + ->method('setContentType') + ->with('application/json'); + + $subject->__invoke($input, $output); + } +} diff --git a/tests/Component/Error/ConfigurablePlainErrorTest.php b/tests/Component/Error/ConfigurablePlainErrorTest.php new file mode 100644 index 0000000..ce80670 --- /dev/null +++ b/tests/Component/Error/ConfigurablePlainErrorTest.php @@ -0,0 +1,48 @@ +createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $output->expects(static::once()) + ->method('setStatusCode') + ->with($errorStatusCode); + + $output->expects(static::once()) + ->method('setOutput') + ->with('404: Not Found'); + + $output->expects(static::once()) + ->method('setContentType') + ->with('text/plain'); + + $subject->__invoke($input, $output); + } +} diff --git a/tests/Component/Error/ErrorHandlerTest.php b/tests/Component/Error/ErrorHandlerTest.php new file mode 100644 index 0000000..54d7300 --- /dev/null +++ b/tests/Component/Error/ErrorHandlerTest.php @@ -0,0 +1,77 @@ +createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + $outputHandler = $this->createMock(OutputHandlerInterface::class); + $errorRegistry = $this->createMock(ErrorRegistryInterface::class); + $subject = new ErrorHandler($input, $output, $outputHandler, $errorRegistry); + + $error = $this->createMock(ErrorInterface::class); + $errorRegistry->expects(static::exactly(2)) + ->method('getError') + ->withConsecutive([404], [500]) + ->willReturnOnConsecutiveCalls( + new Exception(), + $this->returnValue($error) + ); + + $error->expects(static::once()) + ->method('__invoke') + ->with($input, $output); + + $outputHandler->expects(static::once()) + ->method('__invoke') + ->with($input, $output); + + $subject->outputByCode(404); + + $newOutputHandler = $this->createMock(OutputHandlerInterface::class); + $subject->setOutputHandler($newOutputHandler); + + $newErrorRegistry = $this->createMock(ErrorRegistryInterface::class); + $subject->setErrorRegistry($newErrorRegistry); + + $exception = new MethodNotAllowedException(); + + $input->expects(static::once()) + ->method('setParameter') + ->with('exception', $exception); + + $subject->outputByException($exception); + } +} diff --git a/tests/Component/Error/ErrorRegistryTest.php b/tests/Component/Error/ErrorRegistryTest.php new file mode 100644 index 0000000..9f47bd4 --- /dev/null +++ b/tests/Component/Error/ErrorRegistryTest.php @@ -0,0 +1,41 @@ +assertEquals(false, $subject->hasError($code)); + + $error = $this->createMock(ErrorInterface::class); + $subject->setError($code, $error); + + $this->assertEquals(true, $subject->hasError($code)); + + $this->assertSame($error, $subject->getError($code)); + } +} diff --git a/tests/Component/Output/CodecOutputConverterTest.php b/tests/Component/Output/CodecOutputConverterTest.php new file mode 100644 index 0000000..83a1df9 --- /dev/null +++ b/tests/Component/Output/CodecOutputConverterTest.php @@ -0,0 +1,84 @@ +createMock(TranslatorInterface::class); + $codecRegistry = $this->createMock(CodecRegistryInterface::class); + $subject = new CodecOutputConverter($mimeToCodec, $codecRegistry); + + $output = $this->createMock(OutputInterface::class); + $encoder = $this->createMock(EncoderInterface::class); + + $codecRegistry->expects(static::once()) + ->method('getEncoder') + ->with('json') + ->willReturn($encoder); + + $mimeToCodec->expects(static::once()) + ->method('getRight') + ->with('application/json') + ->willReturn('json'); + + $output->expects(static::once()) + ->method('getContentType') + ->willReturn('application/json'); + + $output->expects(static::once()) + ->method('getOutput') + ->willReturn(['foo' => 'bar']); + + $encoder->expects(static::once()) + ->method('encode') + ->with(['foo' => 'bar']) + ->willReturn('{"foo":"bar"}'); + + $this->assertEquals('{"foo":"bar"}', $subject->__invoke($output)); + } + + /** + * @covers ::__invoke + * @covers ::__construct + * + * @return void + */ + public function testInvokeError(): void + { + $mimeToCodec = $this->createMock(TranslatorInterface::class); + $codecRegistry = $this->createMock(CodecRegistryInterface::class); + $subject = new CodecOutputConverter($mimeToCodec, $codecRegistry); + + $output = $this->createMock(OutputInterface::class); + + $output->expects(static::once()) + ->method('getContentType') + ->willThrowException(new Exception()); + + $this->assertEquals(null, $subject->__invoke($output)); + } +} diff --git a/tests/Component/Output/HeaderHandlerTest.php b/tests/Component/Output/HeaderHandlerTest.php new file mode 100644 index 0000000..f490a0f --- /dev/null +++ b/tests/Component/Output/HeaderHandlerTest.php @@ -0,0 +1,78 @@ +createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + $request = $this->createMock(RequestInterface::class); + + $input->expects(static::once()) + ->method('getRequest') + ->willReturn($request); + + $request->expects(static::once()) + ->method('getServerVariable') + ->with('SERVER_PROTOCOL') + ->willReturn('HTTP/2.0'); + + $output->expects(static::once()) + ->method('getStatusCode') + ->wilLReturn(200); + + $output->expects(static::once()) + ->method('getHeaderKeys') + ->willReturn(['Foo', 'Bar', 'Content-Type']); + + $output->expects(static::exactly(3)) + ->method('hasHeader') + ->withConsecutive(['Foo'], ['Bar'], ['Content-Type']) + ->willReturn(true); + + $output->expects(static::exactly(2)) + ->method('getHeader') + ->withConsecutive(['Foo'], ['Bar']) + ->willReturnOnConsecutiveCalls('Baz', 'Qux'); + + $output->expects(static::once()) + ->method('getContentType') + ->willReturn('application/json'); + + $subject->__invoke($input, $output); + + $this->assertEquals( + [ + 'Foo: Baz', + 'Bar: Qux', + 'Content-Type: application/json' + ], + xdebug_get_headers() + ); + } +} diff --git a/tests/Component/Output/OutputHandlerTest.php b/tests/Component/Output/OutputHandlerTest.php new file mode 100644 index 0000000..588dd27 --- /dev/null +++ b/tests/Component/Output/OutputHandlerTest.php @@ -0,0 +1,78 @@ +createMock(HeaderHandlerInterface::class); + $outputConverter = $this->createMock(OutputConverterInterface::class); + $subject = new OutputHandler($headerHandler, $outputConverter); + + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $outputConverter->expects(static::once()) + ->method('__invoke') + ->with($output) + ->willReturn('foo'); + + $headerHandler->expects(static::once()) + ->method('__invoke') + ->with($input, $output); + + ob_start(); + $subject->__invoke($input, $output); + ob_end_clean(); + } + + /** + * @covers ::__invoke + * @covers ::__construct + * + * @return void + */ + public function testInvokeFail(): void + { + $headerHandler = $this->createMock(HeaderHandlerInterface::class); + $outputConverter = $this->createMock(OutputConverterInterface::class); + $subject = new OutputHandler($headerHandler, $outputConverter); + + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $outputConverter->expects(static::once()) + ->method('__invoke') + ->with($output) + ->willReturn(null); + + $this->expectException(NotAcceptedException::class); + + $subject->__invoke($input, $output); + } +} diff --git a/tests/Component/Output/PlainOutputConverterTest.php b/tests/Component/Output/PlainOutputConverterTest.php new file mode 100644 index 0000000..c37f78e --- /dev/null +++ b/tests/Component/Output/PlainOutputConverterTest.php @@ -0,0 +1,87 @@ +createMock(OutputInterface::class); + + $output->expects(static::once()) + ->method('getOutput') + ->willReturn('foo'); + + $output->expects(static::once()) + ->method('getContentType') + ->willReturn('text/plain'); + + $this->assertEquals('foo', $subject->__invoke($output)); + } + + /** + * @covers ::__invoke + * @covers ::__construct + * + * @return void + */ + public function testInvokeOutputError(): void + { + $subject = new PlainOutputConverter(); + + $output = $this->createMock(OutputInterface::class); + + $output->expects(static::once()) + ->method('getOutput') + ->willReturn(['message' => 'Not Found', 'error_code' => 404]); + + $output->expects(static::once()) + ->method('getContentType') + ->willReturn('text/plain'); + + $this->assertEquals('404: Not Found', $subject->__invoke($output)); + } + + /** + * @covers ::__invoke + * @covers ::__construct + * + * @return void + */ + public function testInvokeNoOutput(): void + { + $subject = new PlainOutputConverter(); + + $output = $this->createMock(OutputInterface::class); + + $output->expects(static::once()) + ->method('getOutput') + ->willReturn('foo'); + + $output->expects(static::once()) + ->method('getContentType') + ->willReturn('application/json'); + + $this->assertEquals(null, $subject->__invoke($output)); + } +} diff --git a/tests/Component/Registry/AuthorizationRegistryTest.php b/tests/Component/Registry/AuthorizationRegistryTest.php new file mode 100644 index 0000000..548288a --- /dev/null +++ b/tests/Component/Registry/AuthorizationRegistryTest.php @@ -0,0 +1,37 @@ +assertEquals(false, $subject->has('foo')); + $subject->register('foo', 'bar'); + $this->assertEquals(true, $subject->has('foo')); + $this->assertEquals('bar', $subject->get('foo')); + $this->assertEquals(['foo' => 'bar'], $subject->toArray()); + } +} diff --git a/tests/Component/Registry/RouteGroupRegistryTest.php b/tests/Component/Registry/RouteGroupRegistryTest.php new file mode 100644 index 0000000..4e7aa92 --- /dev/null +++ b/tests/Component/Registry/RouteGroupRegistryTest.php @@ -0,0 +1,39 @@ +createMock(RouteGroupInterface::class); + + $this->assertEquals(false, $subject->has('foo')); + $subject->register('foo', $routeGroup); + $this->assertEquals(true, $subject->has('foo')); + $this->assertEquals($routeGroup, $subject->get('foo')); + $this->assertEquals(['foo'], $subject->getKeys()); + } +} diff --git a/tests/Component/Request/AuthorizationHandlerTest.php b/tests/Component/Request/AuthorizationHandlerTest.php new file mode 100644 index 0000000..fe2ae02 --- /dev/null +++ b/tests/Component/Request/AuthorizationHandlerTest.php @@ -0,0 +1,139 @@ +createMock(ServiceFactoryInterface::class); + $registry = $this->createMock(AuthorizationRegistryInterface::class); + $subject = new AuthorizationHandler($serviceFactory, $registry); + $authorization = $this->createMock(AuthorizationInterface::class); + + $authorizationKeys = ['foo']; + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $registry->expects(static::once()) + ->method('has') + ->with('foo') + ->willReturn(true); + + $serviceFactory->expects(static::once()) + ->method('create') + ->with('bar') + ->willReturn($authorization); + + $registry->expects(static::once()) + ->method('get') + ->with('foo') + ->willReturn('bar'); + + $authorization->expects(static::once()) + ->method('isAllowed') + ->with($input, $output) + ->willReturn(true); + + $this->assertEquals( + true, + $subject->pass($authorizationKeys, $input, $output) + ); + } + + /** + * @covers ::pass + * @covers ::getAuthorizationByKey + * @covers ::__construct + * + * @return void + */ + public function testNoPass(): void + { + $serviceFactory = $this->createMock(ServiceFactoryInterface::class); + $registry = $this->createMock(AuthorizationRegistryInterface::class); + $subject = new AuthorizationHandler($serviceFactory, $registry); + $authorization = $this->createMock(AuthorizationInterface::class); + + $authorizationKeys = ['foo']; + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $registry->expects(static::once()) + ->method('has') + ->with('foo') + ->willReturn(true); + + $serviceFactory->expects(static::once()) + ->method('create') + ->with('bar') + ->willReturn($authorization); + + $registry->expects(static::once()) + ->method('get') + ->with('foo') + ->willReturn('bar'); + + $authorization->expects(static::once()) + ->method('isAllowed') + ->with($input, $output) + ->willReturn(false); + + $this->assertEquals( + false, + $subject->pass($authorizationKeys, $input, $output) + ); + } + + /** + * @covers ::pass + * @covers ::getAuthorizationByKey + * @covers ::__construct + * + * @return void + */ + public function testPassError(): void + { + $serviceFactory = $this->createMock(ServiceFactoryInterface::class); + $registry = $this->createMock(AuthorizationRegistryInterface::class); + $subject = new AuthorizationHandler($serviceFactory, $registry); + + $authorizationKeys = ['foo']; + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $registry->expects(static::once()) + ->method('has') + ->with('foo') + ->willReturn(false); + + $this->expectException(UnauthorizedException::class); + + $subject->pass($authorizationKeys, $input, $output); + } +} diff --git a/tests/Component/Router/GroupRouterTest.php b/tests/Component/Router/GroupRouterTest.php new file mode 100644 index 0000000..bc8154a --- /dev/null +++ b/tests/Component/Router/GroupRouterTest.php @@ -0,0 +1,189 @@ +createMock(ErrorHandlerInterface::class); + $outputHandler = $this->createMock(OutputHandlerInterface::class); + $serviceFactory = $this->createMock(ServiceFactoryInterface::class); + $authorizationHandler = $this->createMock(AuthorizationHandlerInterface::class); + $pathMatcher = $this->createMock(PathMatcherInterface::class); + $request = $this->createMock(RequestInterface::class); + $uri = $this->createMock(UriInterface::class); + $routeGroup = $this->createMock(RouteGroupInterface::class); + $subject = new GroupRouter( + $errorHandler, + $outputHandler, + $serviceFactory, + $authorizationHandler, + $pathMatcher, + $routeGroup + ); + + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + $route = $this->createMock(RouteInterface::class); + + $input->method('getRequest') + ->willReturn($request); + + $request->method('getUri') + ->willReturn($uri); + + $request->method('getMethod') + ->willReturn('GET'); + + $route->method('getMethods') + ->willReturn(['GET']); + + $uri->expects(static::once()) + ->method('getPort') + ->willReturn($port); + + $routeGroup->expects(static::once()) + ->method('getPorts') + ->willReturn($allowedPorts); + + $routeGroup->method('getHosts') + ->willReturn($hosts); + + $uri->method('getHost') + ->willReturn($requestHost); + + $routeGroup->method('getErrorRegistryService') + ->willReturn('error.registry.service'); + + $serviceFactory->method('create') + ->withConsecutive(['error.registry.service'], ['']) + ->willReturnOnConsecutiveCalls( + $this->createMock(ErrorRegistryInterface::class), + $this->createMock(EndpointInterface::class), + ); + + $routeGroup->method('getAuthorizations') + ->willReturn(['foo']); + + $authorizationHandler->method('pass') + ->withConsecutive([['foo'], $input, $output], [[], $input, $output]) + ->willReturn($authorized); + + $routeGroup->method('getRoute') + ->willReturn($route); + + $uri->method('getPath') + ->willReturn(''); + + $pathMatcher->method('__invoke') + ->with($route, '/') + ->willReturn( + [ + 'path' => '', + 'parameters' => [] + ] + ); + + if ($throws) { + $this->expectException(HttpException::class); + } + + $subject->__invoke($input, $output); + } + + /** + * @return array + */ + public function routeProvider(): array + { + return [ + [ + true, + 80, + [443], + [ + 'example.com' + ], + '', + false + ], + [ + true, + 80, + [80, 443], + [ + 'example.com' + ], + '', + false + ], + [ + true, + 80, + [80, 443], + [ + '*.example.com' + ], + 'test.example.com', + false + ], + [ + false, + 80, + [80, 443], + [ + '*.example.com' + ], + 'test.example.com', + true + ] + ]; + } +} diff --git a/tests/Component/Router/PathMatcherTest.php b/tests/Component/Router/PathMatcherTest.php new file mode 100644 index 0000000..f3f434b --- /dev/null +++ b/tests/Component/Router/PathMatcherTest.php @@ -0,0 +1,104 @@ +createMock(RouteInterface::class); + + $route->expects(static::once()) + ->method('getPath') + ->willReturn($routePath); + + $this->assertEquals($result, $subject->__invoke($route, $path)); + } + + /** + * @return array + */ + public function routeDataProvider(): array + { + return [ + [ + '', + 'foo', + [ + 'path' => 'foo', + 'parameters' => [] + ] + ], + [ + '/foo/bar/baz/1/', + 'foo/bar/baz/1', + [ + 'path' => 'foo/bar/baz/1', + 'parameters' => [] + ] + ], + [ + 'foo/bar/baz/1', + 'foo/bar/baz/1', + [ + 'path' => 'foo/bar/baz/1', + 'parameters' => [] + ] + ], + [ + 'foo/bar/baz/1/foo', + 'foo/bar/baz/1', + null + ], + [ + 'foo/bar/baz/{param1}', + 'foo/bar/baz/1', + [ + 'path' => 'foo/bar/baz/1', + 'parameters' => [ + 'param1' => 1 + ] + ] + ], + [ + 'foo/bar/{param1}/foo', + 'foo/bar/baz/1', + null + ], + [ + 'foo/bar/baz/{param1}/{param2}', + 'foo/bar/baz/1', + null + ] + ]; + } +} diff --git a/tests/Component/Router/RouteGroupTest.php b/tests/Component/Router/RouteGroupTest.php new file mode 100644 index 0000000..24c8b9a --- /dev/null +++ b/tests/Component/Router/RouteGroupTest.php @@ -0,0 +1,54 @@ +createMock(RouteInterface::class); + $errorRegistryService = 'error.registry.service'; + $subject = new RouteGroup( + $ports, + $hosts, + $route, + $errorRegistryService + ); + + $this->assertEquals($ports, $subject->getPorts()); + $this->assertEquals($hosts, $subject->getHosts()); + $this->assertEquals($route, $subject->getRoute()); + $this->assertEquals( + $errorRegistryService, + $subject->getErrorRegistryService() + ); + + $subject->addAuthorization('foo'); + $this->assertEquals(['foo'], $subject->getAuthorizations()); + } +} diff --git a/tests/Component/Router/RouteTest.php b/tests/Component/Router/RouteTest.php new file mode 100644 index 0000000..9b5b35c --- /dev/null +++ b/tests/Component/Router/RouteTest.php @@ -0,0 +1,75 @@ +createMock(RouteInterface::class); + $subject = new Route( + $path, + $service, + $methods, + $outputService, + $errorRegistryService, + $route + ); + + $subject->addRoute($route); + + $this->assertEquals($path, $subject->getPath()); + $this->assertEquals($service, $subject->getService()); + $this->assertEquals([$route, $route], $subject->getRoutes()); + $this->assertEquals( + $outputService, + $subject->getOutputHandlerService() + ); + + $this->assertEquals(['GET'], $subject->getMethods()); + $this->assertEquals( + $errorRegistryService, + $subject->getErrorRegistryService() + ); + + $this->assertEquals( + $errorRegistryService, + $subject->getErrorRegistryService() + ); + + $subject->addAuthorization('foo'); + + $this->assertEquals(['foo'], $subject->getAuthorizations()); + } +} diff --git a/tests/Component/Router/RouterTest.php b/tests/Component/Router/RouterTest.php new file mode 100644 index 0000000..fc05de0 --- /dev/null +++ b/tests/Component/Router/RouterTest.php @@ -0,0 +1,255 @@ +createMock(ErrorHandlerInterface::class); + $outputHandler = $this->createMock(OutputHandlerInterface::class); + $serviceFactory = $this->createMock(ServiceFactoryInterface::class); + $authorizationHandler = $this->createMock(AuthorizationHandlerInterface::class); + $route = $this->createMock(RouteInterface::class); + $pathMatcher = $this->createMock(PathMatcherInterface::class); + $request = $this->createMock(RequestInterface::class); + $uri = $this->createMock(UriInterface::class); + $subject = new Router( + $errorHandler, + $outputHandler, + $serviceFactory, + $authorizationHandler, + $route, + $pathMatcher + ); + + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + + $input->method('getRequest') + ->willReturn($request); + + $request->expects(static::once()) + ->method('getUri') + ->willReturn($uri); + + $uri->expects(static::once()) + ->method('getPath') + ->willReturn($path); + + $pathMatcher->method('__invoke') + ->willReturnOnConsecutiveCalls(...$matchInfo); + + $route->method('getOutputHandlerService') + ->willReturn('foo'); + + $route->method('getErrorRegistryService') + ->willReturn('bar'); + + $route->method('getService') + ->willReturn('baz'); + + $serviceFactory->method('create') + ->withConsecutive(['foo'], ['bar'], ['baz']) + ->willReturnOnConsecutiveCalls( + $this->createMock(OutputHandlerInterface::class), + $this->createMock(ErrorRegistryInterface::class), + $this->createMock($endpointClass) + ); + + $route->method('getAuthorizations') + ->willReturn(['baz']); + + $authorizationHandler->method('pass') + ->with(['baz'], $input, $output) + ->willReturn($authPass); + + $request->method('getMethod') + ->willReturn($requestMethod); + + $route->method('getMethods') + ->willReturn($allowedMethods); + + $route->method('getRoutes') + ->willReturn($subRoutes); + + if ($throws) { + $this->expectException(Throwable::class); + } + + $subject->__invoke($input, $output); + } + + /** + * @return array + */ + public function routerProvider(): array + { + return [ + [ + true, + '/', + [ + null + ], + 'GET', + ['GET'], + false, + EndpointInterface::class, + [] + ], + [ + true, + '/', + [ + [ + 'path' => '', + 'parameters' => [] + ] + ], + 'GET', + ['POST'], + false, + EndpointInterface::class, + [] + ], + [ + true, + '/', + [ + [ + 'path' => '', + 'parameters' => [] + ] + ], + 'GET', + ['GET'], + false, + EndpointInterface::class, + [] + ], + [ + true, + '/', + [ + [ + 'path' => '', + 'parameters' => [] + ] + ], + 'GET', + ['GET'], + true, + RouteInterface::class, + [] + ], + [ + true, + 'foo/1', + [ + [ + 'path' => 'foo', + 'parameters' => [ + 'param1' => '1' + ] + ] + ], + 'GET', + ['GET'], + true, + EndpointInterface::class, + [] + ], + [ + false, + 'foo/1', + [ + [ + 'path' => '', + 'parameters' => [ + 'param1' => '1' + ] + ] + ], + 'GET', + ['GET'], + true, + EndpointInterface::class, + [] + ], + [ + true, + 'foo/1', + [ + [ + 'path' => 'foo', + 'parameters' => [ + 'param1' => '1' + ] + ], + null + ], + 'GET', + ['GET'], + true, + EndpointInterface::class, + [$this->createMock(RouteInterface::class)] + ] + ]; + } +} diff --git a/tests/Factory/InputFactoryTest.php b/tests/Factory/InputFactoryTest.php new file mode 100644 index 0000000..9dd448a --- /dev/null +++ b/tests/Factory/InputFactoryTest.php @@ -0,0 +1,132 @@ +createMock(TranslatorInterface::class); + $codec = $this->createMock(DecoderInterface::class); + $codecRegistry = $this->createMock(CodecRegistryInterface::class); + $uriFactory = $this->createMock(UriFactory::class); + + $mimeToCodec->expects(static::once()) + ->method('getRight') + ->with('application/json') + ->willReturn('json-codec'); + + $codecRegistry->expects(static::once()) + ->method('getDecoder') + ->with('json-codec') + ->willReturn($codec); + + $codec->expects(static::once()) + ->method('decode') + ->with('{"foo": "bar"}') + ->willReturn(['foo' => 'bar']); + + $streamInput = tempnam(sys_get_temp_dir(), 'testRead'); + $writeStream = fopen($streamInput, 'w'); + fwrite($writeStream, '{"foo": "bar"}'); + + $subject = new InputFactory( + $mimeToCodec, + $codecRegistry, + $uriFactory, + $streamInput + ); + + $server = [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_HOST' => 'foo.bar', + 'HTTP_CONNECTION' => 'keep-alive', + 'HTTP_CACHE_CONTROL' => 'max-age=0', + 'HTTP_UPGRADE_INSECURE_REQUESTS' => '1', + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', + 'HTTP_ACCEPT_ENCODING' => 'gzip, deflate', + 'HTTP_ACCEPT_LANGUAGE' => 'en-US,en;q=0.9', + 'SERVER_NAME' => 'foo.bar', + 'SERVER_ADDR' => '127.0.0.1', + 'SERVER_PORT' => '80', + 'REMOTE_ADDR' => '127.0.0.1', + 'REQUEST_SCHEME' => 'http', + 'SERVER_PROTOCOL' => 'HTTP/2.0', + 'REQUEST_METHOD' => 'GET', + 'QUERY_STRING' => 'foo=bar&baz=qux', + 'REQUEST_URI' => '/pub/index.php', + 'SCRIPT_NAME' => '/pub/index.php', + 'PHP_SELF' => '/pub/index.php' + ]; + + $this->assertInstanceOf( + InputInterface::class, + $subject->create($server, [], [], [], []) + ); + } + + /** + * @covers ::create + * @covers ::__construct + * + * @return void + */ + public function testCreateFailure(): void + { + $mimeToCodec = $this->createMock(TranslatorInterface::class); + $codecRegistry = $this->createMock(CodecRegistryInterface::class); + $uriFactory = $this->createMock(UriFactory::class); + + $mimeToCodec->expects(static::once()) + ->method('getRight') + ->with('application/json') + ->willReturn('json-codec'); + + $codecRegistry->expects(static::once()) + ->method('getDecoder') + ->with('json-codec') + ->willThrowException(new Exception()); + + $subject = new InputFactory( + $mimeToCodec, + $codecRegistry, + $uriFactory + ); + + $server = [ + 'CONTENT_TYPE' => 'application/json', + 'REQUEST_URI' => '/pub/index.php' + ]; + + $this->expectException(UnsupportedMediaTypeException::class); + + $subject->create($server, [], [], [], []); + } +} diff --git a/tests/Factory/OutputFactoryTest.php b/tests/Factory/OutputFactoryTest.php new file mode 100644 index 0000000..3022d7a --- /dev/null +++ b/tests/Factory/OutputFactoryTest.php @@ -0,0 +1,50 @@ +createMock(InputInterface::class); + $request = $this->createMock(RequestInterface::class); + + $input->expects(static::once()) + ->method('getRequest') + ->willReturn($request); + + $request->expects(static::once()) + ->method('hasHeader') + ->with('Accept') + ->willReturn(true); + + $request->expects(static::once()) + ->method('getHeader') + ->with('Accept') + ->willReturn('application/json;q=1'); + + $this->assertInstanceOf(OutputInterface::class, $subject->create($input)); + } +}