├── LICENSE ├── composer.json └── src ├── Console ├── Command │ └── RouteDumpCommand.php └── DI │ └── ConsolePlugin.php ├── Core ├── Adjuster │ └── FileResponseAdjuster.php ├── Annotation │ └── Controller │ │ ├── Id.php │ │ ├── Method.php │ │ ├── Negotiation.php │ │ ├── Negotiations.php │ │ ├── OpenApi.php │ │ ├── Path.php │ │ ├── RequestBody.php │ │ ├── RequestParameter.php │ │ ├── RequestParameters.php │ │ ├── Response.php │ │ ├── Responses.php │ │ └── Tag.php ├── Application │ ├── Application.php │ ├── BaseApplication.php │ └── IApplication.php ├── DI │ ├── ApiExtension.php │ ├── Helpers.php │ ├── Loader │ │ ├── AbstractContainerLoader.php │ │ ├── DoctrineAnnotationLoader.php │ │ ├── ILoader.php │ │ └── NeonLoader.php │ ├── LoaderFactory │ │ └── DualReaderFactory.php │ └── Plugin │ │ ├── CoreDecoratorPlugin.php │ │ ├── CoreMappingPlugin.php │ │ ├── CoreSchemaPlugin.php │ │ ├── CoreServicesPlugin.php │ │ ├── Plugin.php │ │ ├── PluginCompiler.php │ │ └── PluginManager.php ├── Decorator │ ├── DecoratorManager.php │ ├── IErrorDecorator.php │ ├── IRequestDecorator.php │ ├── IResponseDecorator.php │ ├── RequestEntityDecorator.php │ └── RequestParametersDecorator.php ├── Dispatcher │ ├── CoreDispatcher.php │ ├── DecoratedDispatcher.php │ ├── DispatchError.php │ ├── IDispatcher.php │ └── JsonDispatcher.php ├── ErrorHandler │ ├── IErrorHandler.php │ ├── PsrLogErrorHandler.php │ └── SimpleErrorHandler.php ├── Exception │ ├── Api │ │ ├── ClientErrorException.php │ │ ├── MessageException.php │ │ ├── ServerErrorException.php │ │ └── ValidationException.php │ ├── ApiException.php │ ├── AttributeException.php │ ├── ExceptionExtra.php │ ├── Logical │ │ ├── InvalidArgumentException.php │ │ ├── InvalidDependencyException.php │ │ ├── InvalidSchemaException.php │ │ └── InvalidStateException.php │ ├── LogicalException.php │ ├── Runtime │ │ ├── EarlyReturnResponseException.php │ │ ├── InvalidArgumentTypeException.php │ │ └── SnapshotException.php │ └── RuntimeException.php ├── Handler │ ├── IHandler.php │ ├── ServiceCallback.php │ └── ServiceHandler.php ├── Http │ ├── ApiRequest.php │ ├── ApiResponse.php │ ├── RequestAttributes.php │ └── ResponseAttributes.php ├── Mapping │ ├── Parameter │ │ ├── BooleanTypeMapper.php │ │ ├── DateTimeTypeMapper.php │ │ ├── EnumTypeMapper.php │ │ ├── FloatTypeMapper.php │ │ ├── ITypeMapper.php │ │ ├── IntegerTypeMapper.php │ │ └── StringTypeMapper.php │ ├── Request │ │ ├── AbstractEntity.php │ │ ├── BasicEntity.php │ │ └── IRequestEntity.php │ ├── RequestEntityMapping.php │ ├── RequestParameterMapping.php │ ├── Response │ │ ├── AbstractEntity.php │ │ ├── BasicEntity.php │ │ └── IResponseEntity.php │ ├── TReflectionProperties.php │ └── Validator │ │ ├── BasicValidator.php │ │ ├── IEntityValidator.php │ │ ├── NullValidator.php │ │ └── SymfonyValidator.php ├── Router │ ├── IRouter.php │ └── SimpleRouter.php ├── Schema │ ├── Builder │ │ └── Controller │ │ │ ├── Controller.php │ │ │ └── Method.php │ ├── Endpoint.php │ ├── EndpointHandler.php │ ├── EndpointNegotiation.php │ ├── EndpointParameter.php │ ├── EndpointRequestBody.php │ ├── EndpointResponse.php │ ├── Hierarchy │ │ ├── ControllerMethodPair.php │ │ ├── HierarchicalNode.php │ │ └── HierarchyBuilder.php │ ├── Schema.php │ ├── SchemaBuilder.php │ ├── SchemaInspector.php │ ├── Serialization │ │ ├── ArrayHydrator.php │ │ ├── ArraySerializator.php │ │ ├── IDecorator.php │ │ ├── IHydrator.php │ │ └── ISerializator.php │ ├── Validation │ │ ├── ControllerPathValidation.php │ │ ├── ControllerValidation.php │ │ ├── FullpathValidation.php │ │ ├── GroupPathValidation.php │ │ ├── IValidation.php │ │ ├── IdValidation.php │ │ ├── NegotiationValidation.php │ │ ├── PathValidation.php │ │ ├── RequestBodyValidation.php │ │ └── RequestParameterValidation.php │ └── Validator │ │ └── SchemaBuilderValidator.php ├── UI │ └── Controller │ │ └── IController.php └── Utils │ ├── Helpers.php │ └── Regex.php ├── Debug ├── DI │ └── DebugPlugin.php ├── Negotiation │ └── Transformer │ │ ├── DebugDataTransformer.php │ │ └── DebugTransformer.php ├── Schema │ └── Serialization │ │ └── DebugSchemaDecorator.php └── Tracy │ ├── BlueScreen │ ├── ApiBlueScreen.php │ └── ValidationBlueScreen.php │ └── Panel │ ├── ApiPanel.php │ └── templates │ ├── panel.phtml │ └── tab.phtml ├── Middlewares ├── ApiMiddleware.php └── DI │ └── MiddlewaresPlugin.php ├── Negotiation ├── ContentNegotiation.php ├── DI │ └── NegotiationPlugin.php ├── Decorator │ └── ResponseEntityDecorator.php ├── DefaultNegotiator.php ├── FallbackNegotiator.php ├── Http │ ├── AbstractEntity.php │ ├── ArrayEntity.php │ ├── CsvEntity.php │ ├── MappingEntity.php │ ├── ObjectEntity.php │ └── ScalarEntity.php ├── INegotiator.php ├── SuffixNegotiator.php └── Transformer │ ├── AbstractTransformer.php │ ├── CsvTransformer.php │ ├── ITransformer.php │ ├── JsonTransformer.php │ ├── JsonUnifyTransformer.php │ └── RendererTransformer.php ├── OpenApi ├── DI │ └── OpenApiPlugin.php ├── ISchemaBuilder.php ├── SchemaBuilder.php ├── SchemaDefinition │ ├── ArrayDefinition.php │ ├── BaseDefinition.php │ ├── CoreDefinition.php │ ├── Entity │ │ ├── EntityAdapter.php │ │ └── IEntityAdapter.php │ ├── IDefinition.php │ ├── JsonDefinition.php │ ├── NeonDefinition.php │ └── YamlDefinition.php └── SchemaType │ ├── BaseSchemaType.php │ ├── ISchemaType.php │ └── UnknownSchemaType.php └── Presenter ├── ApiPresenter.php └── ApiRoute.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Apitte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contributte/apitte", 3 | "description": "An opinionated and enjoyable API framework based on Nette Framework. Supporting content negotiation, debugging, middlewares, attributes, annotations and loving openapi/swagger.", 4 | "keywords": [ 5 | "api", 6 | "apitte", 7 | "http", 8 | "rest", 9 | "nette", 10 | "annotation" 11 | ], 12 | "type": "library", 13 | "license": "MIT", 14 | "homepage": "https://github.com/contributte/apitte", 15 | "authors": [ 16 | { 17 | "name": "Milan Felix Šulc", 18 | "homepage": "https://f3l1x.io" 19 | } 20 | ], 21 | "require": { 22 | "php": ">=8.1", 23 | "ext-json": "*", 24 | "nette/di": "^3.1.8", 25 | "contributte/psr7-http-message": "^0.9.0 || ^0.10.0", 26 | "contributte/middlewares": "^0.11.0 || ^0.12.0", 27 | "contributte/openapi": "^0.1.0", 28 | "doctrine/annotations": "^1.14.3 || ^2.0.0", 29 | "koriym/attributes": "^1.0.5", 30 | "nette/utils": "^4.0.0" 31 | }, 32 | "require-dev": { 33 | "contributte/qa": "^0.4", 34 | "contributte/dev": "^0.4", 35 | "contributte/tester": "^0.3", 36 | "contributte/phpstan": "^0.1", 37 | "mockery/mockery": "^1.6.6", 38 | "nette/application": "^3.1.4", 39 | "nette/di": "^3.1.8", 40 | "nette/http": "^3.2.3", 41 | "psr/log": "^2.0.0 || ^3.0.0", 42 | "symfony/console": "^6.4.0 || ^7.0.0", 43 | "symfony/translation": "^6.4.0 | ^7.0.0", 44 | "symfony/validator": "^6.4.0 || ^7.0.0", 45 | "symfony/yaml": "^6.4.0 || ^7.0.0", 46 | "tracy/tracy": "^2.10.5" 47 | }, 48 | "provide": { 49 | "psr/http-message-implementation": "1.0" 50 | }, 51 | "autoload": { 52 | "psr-4": { 53 | "Apitte\\": "src" 54 | } 55 | }, 56 | "autoload-dev": { 57 | "psr-4": { 58 | "Tests\\": "tests" 59 | } 60 | }, 61 | "minimum-stability": "dev", 62 | "prefer-stable": true, 63 | "config": { 64 | "sort-packages": true, 65 | "allow-plugins": { 66 | "dealerdirect/phpcodesniffer-composer-installer": true 67 | } 68 | }, 69 | "extra": { 70 | "branch-alias": { 71 | "dev-master": "0.13.x-dev" 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Console/Command/RouteDumpCommand.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 31 | } 32 | 33 | protected function configure(): void 34 | { 35 | $this->setName(self::NAME); 36 | $this->setDescription('Lists all endpoints registered in application'); 37 | } 38 | 39 | protected function execute(InputInterface $input, OutputInterface $output): int 40 | { 41 | $endpoints = $this->schema->getEndpoints(); 42 | 43 | if ($endpoints === []) { 44 | $output->writeln('No endpoints found'); 45 | 46 | return 0; 47 | } 48 | 49 | $table = new Table($output); 50 | $table->setHeaders(self::TABLE_HEADER); 51 | 52 | $style = new TableStyle(); 53 | $style 54 | ->setDefaultCrossingChar(' ') 55 | ->setVerticalBorderChars(' ') 56 | ->setHorizontalBorderChars('', '─') 57 | ->setCellRowContentFormat('%s'); 58 | $table->setStyle($style); 59 | 60 | /** @var Endpoint[][] $endpointsByHandler */ 61 | $endpointsByHandler = []; 62 | foreach ($endpoints as $endpoint) { 63 | $endpointsByHandler[$endpoint->getHandler()->getClass()][] = $endpoint; 64 | } 65 | 66 | foreach ($endpointsByHandler as $groupedEndpoints) { 67 | $previousClass = null; 68 | 69 | foreach ($groupedEndpoints as $endpoint) { 70 | $handler = $endpoint->getHandler(); 71 | $currentClass = $class = $handler->getClass(); 72 | 73 | if ($previousClass === $class) { 74 | $currentClass = ''; 75 | } 76 | 77 | $table->addRow([ 78 | sprintf( 79 | '%s', 80 | implode('|', $endpoint->getMethods()) 81 | ), 82 | $endpoint->getMask(), 83 | $currentClass, 84 | $handler->getMethod(), 85 | $this->formatParameters($endpoint->getParameters()), 86 | ]); 87 | 88 | $previousClass = $class; 89 | } 90 | 91 | if ($groupedEndpoints !== end($endpointsByHandler)) { 92 | $table->addRow(new TableSeparator()); 93 | } 94 | } 95 | 96 | $table->render(); 97 | 98 | return 0; 99 | } 100 | 101 | /** 102 | * @param EndpointParameter[] $parameters 103 | */ 104 | private function formatParameters(array $parameters): string 105 | { 106 | $paramsByIn = []; 107 | 108 | foreach ($parameters as $parameter) { 109 | $paramsByIn[$parameter->getIn()][] = $parameter->getName(); 110 | } 111 | 112 | ksort($paramsByIn); 113 | 114 | $result = ''; 115 | 116 | foreach ($paramsByIn as $in => $params) { 117 | $result .= sprintf('%s', $in) . ': ' . implode(', ', $params); 118 | if ($params !== end($paramsByIn)) { 119 | $result .= ' | '; 120 | } 121 | } 122 | 123 | return $result; 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /src/Console/DI/ConsolePlugin.php: -------------------------------------------------------------------------------- 1 | getContainerBuilder(); 19 | 20 | $builder->addDefinition($this->prefix('console')) 21 | ->setFactory(RouteDumpCommand::class); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/Adjuster/FileResponseAdjuster.php: -------------------------------------------------------------------------------- 1 | withHeader('Content-Type', $contentType) 21 | ->withHeader('Content-Description', 'File Transfer') 22 | ->withHeader('Content-Transfer-Encoding', 'binary') 23 | ->withHeader( 24 | 'Content-Disposition', 25 | ($forceDownload ? 'attachment' : 'inline') 26 | . '; filename="' . $filename . '"' 27 | . '; filename*=utf-8\'\'' . rawurlencode($filename) 28 | ) 29 | ->withHeader('Expires', '0') 30 | ->withHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0') 31 | ->withHeader('Pragma', 'public') 32 | ->withHeader('Content-Length', (string) $stream->getSize()) 33 | ->withBody($stream); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/Id.php: -------------------------------------------------------------------------------- 1 | name = $name; 28 | } 29 | 30 | public function getName(): string 31 | { 32 | return $this->name; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/Method.php: -------------------------------------------------------------------------------- 1 | methods = $methods; 37 | } 38 | 39 | /** 40 | * @return string[] 41 | */ 42 | public function getMethods(): array 43 | { 44 | return $this->methods; 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/Negotiation.php: -------------------------------------------------------------------------------- 1 | suffix = $suffix; 27 | $this->default = $default; 28 | $this->renderer = $renderer; 29 | } 30 | 31 | public function getSuffix(): string 32 | { 33 | return $this->suffix; 34 | } 35 | 36 | public function isDefault(): bool 37 | { 38 | return $this->default; 39 | } 40 | 41 | public function getRenderer(): ?string 42 | { 43 | return $this->renderer; 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/Negotiations.php: -------------------------------------------------------------------------------- 1 | negotiations = $negotiations; 35 | } 36 | 37 | /** 38 | * @return Negotiation[] 39 | */ 40 | public function getNegotiations(): array 41 | { 42 | return $this->negotiations; 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/OpenApi.php: -------------------------------------------------------------------------------- 1 | data = $this->purifyDocblock($data); 23 | } 24 | 25 | public function getData(): string 26 | { 27 | return $this->data; 28 | } 29 | 30 | private function purifyDocblock(string $docblock): string 31 | { 32 | // Removes useless whitespace and * from start of every line 33 | return preg_replace('#\s*\*\/$|^\s*\*\s{0,1}|^\/\*{1,2}#m', '', $docblock); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/Path.php: -------------------------------------------------------------------------------- 1 | path = $path; 28 | } 29 | 30 | public function getPath(): string 31 | { 32 | return $this->path; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/RequestBody.php: -------------------------------------------------------------------------------- 1 | description = $description; 29 | $this->entity = $entity; 30 | $this->required = $required; 31 | $this->validation = $validation; 32 | } 33 | 34 | public function getEntity(): ?string 35 | { 36 | return $this->entity; 37 | } 38 | 39 | public function getDescription(): ?string 40 | { 41 | return $this->description; 42 | } 43 | 44 | public function isRequired(): bool 45 | { 46 | return $this->required; 47 | } 48 | 49 | public function isValidation(): bool 50 | { 51 | return $this->validation; 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/RequestParameter.php: -------------------------------------------------------------------------------- 1 | name = $name; 60 | $this->type = $type; 61 | $this->required = $required; 62 | $this->allowEmpty = $allowEmpty; 63 | $this->deprecated = $deprecated; 64 | $this->description = $description; 65 | $this->in = $in; 66 | $this->enum = $enum; 67 | } 68 | 69 | public function getName(): string 70 | { 71 | return $this->name; 72 | } 73 | 74 | public function getType(): string 75 | { 76 | return $this->type; 77 | } 78 | 79 | public function getDescription(): ?string 80 | { 81 | return $this->description; 82 | } 83 | 84 | public function getIn(): string 85 | { 86 | return $this->in; 87 | } 88 | 89 | public function isRequired(): bool 90 | { 91 | return $this->required; 92 | } 93 | 94 | public function isDeprecated(): bool 95 | { 96 | return $this->deprecated; 97 | } 98 | 99 | public function isAllowEmpty(): bool 100 | { 101 | return $this->allowEmpty; 102 | } 103 | 104 | public function getEnum(): ?array 105 | { 106 | return $this->enum; 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/RequestParameters.php: -------------------------------------------------------------------------------- 1 | validateUniqueNames($parameters); 35 | 36 | $this->parameters = $parameters; 37 | } 38 | 39 | /** 40 | * @return RequestParameter[] 41 | */ 42 | public function getParameters(): array 43 | { 44 | return $this->parameters; 45 | } 46 | 47 | /** 48 | * @param RequestParameter[] $parameters 49 | */ 50 | private function validateUniqueNames(array $parameters): void 51 | { 52 | $takenNames = []; 53 | 54 | foreach ($parameters as $parameter) { 55 | if (!isset($takenNames[$parameter->getIn()][$parameter->getName()])) { 56 | $takenNames[$parameter->getIn()][$parameter->getName()] = $parameter; 57 | } else { 58 | throw new AnnotationException(sprintf( 59 | 'Multiple @RequestParameter annotations with "name=%s" and "in=%s" given. Each parameter must have unique combination of location and name.', 60 | $parameter->getName(), 61 | $parameter->getIn() 62 | )); 63 | } 64 | } 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/Response.php: -------------------------------------------------------------------------------- 1 | code = $code; 32 | $this->entity = $entity; 33 | $this->description = $description; 34 | } 35 | 36 | public function getDescription(): string 37 | { 38 | return $this->description; 39 | } 40 | 41 | public function getCode(): string 42 | { 43 | return $this->code; 44 | } 45 | 46 | public function getEntity(): ?string 47 | { 48 | return $this->entity; 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/Responses.php: -------------------------------------------------------------------------------- 1 | getCode()])) { 39 | $takenCodes[$response->getCode()] = $response; 40 | } else { 41 | throw new AnnotationException(sprintf( 42 | 'Multiple @Response annotations with "code=%s" given. Each response must have unique code.', 43 | $response->getCode() 44 | )); 45 | } 46 | } 47 | 48 | $this->responses = $responses; 49 | } 50 | 51 | /** 52 | * @return Response[] 53 | */ 54 | public function getResponses(): array 55 | { 56 | return $this->responses; 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/Core/Annotation/Controller/Tag.php: -------------------------------------------------------------------------------- 1 | name = $name; 30 | $this->value = $value; 31 | } 32 | 33 | public function getName(): string 34 | { 35 | return $this->name; 36 | } 37 | 38 | public function getValue(): ?string 39 | { 40 | return $this->value; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/Core/Application/Application.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 21 | } 22 | 23 | protected function dispatch(ApiRequest $request): ApiResponse 24 | { 25 | return $this->dispatcher->dispatch($request, new ApiResponse(new Psr7Response())); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Core/Application/BaseApplication.php: -------------------------------------------------------------------------------- 1 | errorHandler = $errorHandler; 24 | } 25 | 26 | public function run(): void 27 | { 28 | $request = new ApiRequest(Psr7ServerRequestFactory::fromSuperGlobal()); 29 | $this->runWith($request); 30 | } 31 | 32 | public function runWith(ApiRequest $request): void 33 | { 34 | try { 35 | $response = $this->dispatch($request); 36 | } catch (Throwable $exception) { 37 | $response = $this->errorHandler->handle(new DispatchError($exception, $request)); 38 | } 39 | 40 | $this->sendResponse($response); 41 | } 42 | 43 | abstract protected function dispatch(ApiRequest $request): ApiResponse; 44 | 45 | protected function sendResponse(ApiResponse $response): void 46 | { 47 | $httpHeader = sprintf( 48 | 'HTTP/%s %s %s', 49 | $response->getProtocolVersion(), 50 | $response->getStatusCode(), 51 | $response->getReasonPhrase() 52 | ); 53 | 54 | header($httpHeader, true, $response->getStatusCode()); 55 | 56 | foreach ($response->getHeaders() as $name => $values) { 57 | $replace = in_array(strtolower($name), self::UNIQUE_HEADERS, true); 58 | foreach ($values as $value) { 59 | header(sprintf('%s: %s', $name, $value), $replace); 60 | } 61 | } 62 | 63 | $stream = $response->getBody(); 64 | 65 | if ($stream->isSeekable()) { 66 | $stream->rewind(); 67 | } 68 | 69 | while (!$stream->eof()) { 70 | echo $stream->read(1024 * 8); 71 | } 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/Core/Application/IApplication.php: -------------------------------------------------------------------------------- 1 | pm = new PluginManager($this); 32 | } 33 | 34 | public function getConfigSchema(): Schema 35 | { 36 | $parameters = $this->getContainerBuilder()->parameters; 37 | 38 | return Expect::structure([ 39 | 'catchException' => Expect::bool(true), 40 | 'debug' => Expect::bool($parameters['debugMode'] ?? false), 41 | 'plugins' => Expect::array()->default([ 42 | CoreServicesPlugin::class => [], 43 | CoreSchemaPlugin::class => [], 44 | ]), 45 | ]); 46 | } 47 | 48 | public function loadConfiguration(): void 49 | { 50 | $config = $this->config; 51 | 52 | // Register all defined plugins 53 | $this->pm->loadPlugins($config->plugins); 54 | 55 | // Load services from all plugins 56 | $this->pm->loadConfigurations(); 57 | } 58 | 59 | public function beforeCompile(): void 60 | { 61 | // Decorate services from all plugins 62 | $this->pm->beforeCompiles(); 63 | } 64 | 65 | public function afterCompile(ClassType $class): void 66 | { 67 | // Decorate services from all plugins 68 | $this->pm->afterCompiles($class); 69 | } 70 | 71 | public function getCompiler(): Compiler 72 | { 73 | return $this->compiler; 74 | } 75 | 76 | public function getName(): string 77 | { 78 | return $this->name; 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/Core/DI/Helpers.php: -------------------------------------------------------------------------------- 1 | getTag($tagname); 19 | $p1 = $tag1 !== null && isset($tag1['priority']) ? $tag1['priority'] : $default; 20 | 21 | $tag2 = $b->getTag($tagname); 22 | $p2 = $tag2 !== null && isset($tag2['priority']) ? $tag2['priority'] : $default; 23 | 24 | if ($p1 === $p2) { 25 | return 0; 26 | } 27 | 28 | return ($p1 < $p2) ? -1 : 1; 29 | }); 30 | 31 | return $definitions; 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/Core/DI/Loader/AbstractContainerLoader.php: -------------------------------------------------------------------------------- 1 | builder = $builder; 17 | } 18 | 19 | /** 20 | * Find controllers in container definitions 21 | * 22 | * @return Definition[] 23 | */ 24 | protected function findControllers(): array 25 | { 26 | return $this->builder->findByType(IController::class); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/Core/DI/Loader/ILoader.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 23 | } 24 | 25 | public function load(SchemaBuilder $builder): SchemaBuilder 26 | { 27 | foreach ($this->schema as $class => $settings) { 28 | $controller = $builder->addController($class); 29 | $controller->setId($settings['id'] ?? null); 30 | $controller->setPath($settings['path'] ?? ''); 31 | $controller->setGroupIds($settings['groupIds'] ?? []); 32 | $controller->setGroupPaths($settings['groupPaths'] ?? []); 33 | $controller->addTags($settings['tags'] ?? []); 34 | $controller->setOpenApi($settings['openapi'] ?? []); 35 | $this->addControllerMethods($controller, $settings['methods'] ?? []); 36 | } 37 | 38 | return $builder; 39 | } 40 | 41 | /** 42 | * @param mixed[] $methodsSettings 43 | */ 44 | private function addControllerMethods(Controller $controller, array $methodsSettings): void 45 | { 46 | foreach ($methodsSettings as $name => $settings) { 47 | $method = $controller->addMethod($name); 48 | $method->setId($settings['id'] ?? null); 49 | $method->setPath($settings['path'] ?? ''); 50 | $method->addHttpMethods($settings['methods'] ?? []); 51 | $method->addTags($settings['tags'] ?? []); 52 | $method->setOpenApi($settings['openapi'] ?? []); 53 | $this->setEndpointParameters($method, $settings['parameters'] ?? []); 54 | $this->setNegotiations($method, $settings['negotiations'] ?? []); 55 | $this->setRequestBody($method, $settings['requestBody'] ?? null); 56 | $this->setResponses($method, $settings['responses'] ?? null); 57 | } 58 | } 59 | 60 | /** 61 | * @param mixed[] $parametersSettings 62 | */ 63 | private function setEndpointParameters(Method $method, array $parametersSettings): void 64 | { 65 | foreach ($parametersSettings as $name => $settings) { 66 | $parameter = $method->addParameter($name, $settings['type'] ?? EndpointParameter::TYPE_STRING); 67 | $parameter->setIn($settings['in'] ?? EndpointParameter::IN_PATH); 68 | $parameter->setDescription($settings['description'] ?? null); 69 | $parameter->setRequired($settings['required'] ?? true); 70 | $parameter->setAllowEmpty($settings['allowEmpty'] ?? false); 71 | $parameter->setDeprecated($settings['deprecated'] ?? false); 72 | $parameter->setEnum($settings['enum'] ?? null); 73 | } 74 | } 75 | 76 | /** 77 | * @param mixed[] $negotiationsSettings 78 | */ 79 | private function setNegotiations(Method $method, array $negotiationsSettings): void 80 | { 81 | foreach ($negotiationsSettings as $suffix => $settings) { 82 | $negotiation = $method->addNegotiation($suffix); 83 | $negotiation->setDefault($settings['default'] ?? false); 84 | $negotiation->setRenderer($settings['renderer'] ?? null); 85 | } 86 | } 87 | 88 | /** 89 | * @param mixed[]|null $requestBodySettings 90 | */ 91 | private function setRequestBody(Method $method, ?array $requestBodySettings): void 92 | { 93 | if ($requestBodySettings === null) { 94 | return; 95 | } 96 | 97 | $requestBody = new EndpointRequestBody(); 98 | 99 | $requestBody->setRequired($requestBodySettings['required'] ?? false); 100 | $requestBody->setDescription($requestBodySettings['description'] ?? null); 101 | $requestBody->setEntity($requestBodySettings['entity'] ?? null); 102 | $requestBody->setValidation($requestBodySettings['validation'] ?? true); 103 | 104 | $method->setRequestBody($requestBody); 105 | } 106 | 107 | /** 108 | * @param mixed[]|null $responses 109 | */ 110 | private function setResponses(Method $method, ?array $responses): void 111 | { 112 | if ($responses === null) { 113 | return; 114 | } 115 | 116 | foreach ($responses as $response) { 117 | $method->addResponse($response['code'], $response['description'] ?? ''); 118 | } 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /src/Core/DI/LoaderFactory/DualReaderFactory.php: -------------------------------------------------------------------------------- 1 | getContainerBuilder(); 28 | 29 | $dispatcherDefinition = $builder->getDefinition($this->extensionPrefix('core.dispatcher')); 30 | assert($dispatcherDefinition instanceof ServiceDefinition); 31 | $dispatcherDefinition->setFactory(DecoratedDispatcher::class); 32 | 33 | $builder->addDefinition($this->prefix('decorator.manager')) 34 | ->setFactory(DecoratorManager::class); 35 | } 36 | 37 | /** 38 | * Decorate services 39 | */ 40 | public function beforePluginCompile(): void 41 | { 42 | $this->compileDecorators(); 43 | } 44 | 45 | protected function compileDecorators(): void 46 | { 47 | $builder = $this->getContainerBuilder(); 48 | $managerDefinition = $builder->getDefinition($this->prefix('decorator.manager')); 49 | assert($managerDefinition instanceof ServiceDefinition); 50 | 51 | $requestDecoratorDefinitions = $builder->findByType(IRequestDecorator::class); 52 | $requestDecoratorDefinitions = Helpers::sortByPriorityInTag(ApiExtension::CORE_DECORATOR_TAG, $requestDecoratorDefinitions); 53 | foreach ($requestDecoratorDefinitions as $decoratorDefinition) { 54 | $managerDefinition->addSetup('addRequestDecorator', [$decoratorDefinition]); 55 | } 56 | 57 | $responseDecoratorDefinitions = $builder->findByType(IResponseDecorator::class); 58 | $responseDecoratorDefinitions = Helpers::sortByPriorityInTag(ApiExtension::CORE_DECORATOR_TAG, $responseDecoratorDefinitions); 59 | foreach ($responseDecoratorDefinitions as $decoratorDefinition) { 60 | $managerDefinition->addSetup('addResponseDecorator', [$decoratorDefinition]); 61 | } 62 | 63 | $errorDecoratorDefinitions = $builder->findByType(IErrorDecorator::class); 64 | $errorDecoratorDefinitions = Helpers::sortByPriorityInTag(ApiExtension::CORE_DECORATOR_TAG, $errorDecoratorDefinitions); 65 | foreach ($errorDecoratorDefinitions as $decoratorDefinition) { 66 | $managerDefinition->addSetup('addErrorDecorator', [$decoratorDefinition]); 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/Core/DI/Plugin/CoreMappingPlugin.php: -------------------------------------------------------------------------------- 1 | */ 31 | private array $defaultTypes = [ 32 | 'string' => StringTypeMapper::class, 33 | 'int' => IntegerTypeMapper::class, 34 | 'float' => FloatTypeMapper::class, 35 | 'bool' => BooleanTypeMapper::class, 36 | 'datetime' => DateTimeTypeMapper::class, 37 | 'enum' => EnumTypeMapper::class, 38 | ]; 39 | 40 | public static function getName(): string 41 | { 42 | return 'mapping'; 43 | } 44 | 45 | /** 46 | * Register services 47 | */ 48 | public function loadPluginConfiguration(): void 49 | { 50 | $builder = $this->getContainerBuilder(); 51 | $config = $this->config; 52 | 53 | $builder->addDefinition($this->prefix('request.parameters.decorator')) 54 | ->setFactory(RequestParametersDecorator::class) 55 | ->addTag(ApiExtension::CORE_DECORATOR_TAG, ['priority' => 100]); 56 | 57 | $builder->addDefinition($this->prefix('request.entity.decorator')) 58 | ->setFactory(RequestEntityDecorator::class) 59 | ->addTag(ApiExtension::CORE_DECORATOR_TAG, ['priority' => 101]); 60 | 61 | $parametersMapping = $builder->addDefinition($this->prefix('request.parameters.mapping')) 62 | ->setFactory(RequestParameterMapping::class); 63 | 64 | foreach ($this->defaultTypes as $type => $mapper) { 65 | if (!array_key_exists($type, $config->types)) { 66 | $parametersMapping->addSetup('addMapper', [$type, $mapper]); 67 | } 68 | } 69 | 70 | foreach ($config->types as $type => $mapper) { 71 | $parametersMapping->addSetup('addMapper', [$type, $mapper]); 72 | } 73 | 74 | $builder->addDefinition($this->prefix('request.entity.mapping.validator')) 75 | ->setType(IEntityValidator::class) 76 | ->setFactory($config->request->validator); 77 | 78 | $builder->addDefinition($this->prefix('request.entity.mapping')) 79 | ->setFactory(RequestEntityMapping::class) 80 | ->addSetup('setValidator', ['@' . $this->prefix('request.entity.mapping.validator')]); 81 | } 82 | 83 | /** 84 | * @return array 85 | */ 86 | public function getAllowedTypes(): array 87 | { 88 | /** @var array $configuredTypes */ 89 | $configuredTypes = array_keys($this->config->types); 90 | 91 | return array_merge(EndpointParameter::TYPES, $configuredTypes); 92 | } 93 | 94 | protected function getConfigSchema(): Schema 95 | { 96 | return Expect::structure([ 97 | 'types' => Expect::arrayOf('string', 'string'), 98 | 'request' => Expect::structure([ 99 | 'validator' => Expect::type('string|array|' . Statement::class)->default(NullValidator::class), 100 | ]), 101 | ]); 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /src/Core/DI/Plugin/CoreServicesPlugin.php: -------------------------------------------------------------------------------- 1 | getContainerBuilder(); 36 | $globalConfig = $this->compiler->getExtension()->getConfig(); 37 | 38 | $builder->addDefinition($this->prefix('dispatcher')) 39 | ->setFactory(JsonDispatcher::class) 40 | ->setType(IDispatcher::class); 41 | 42 | // Catch exception only in debug mode if explicitly enabled 43 | $catchException = !$globalConfig->debug || $globalConfig->catchException; 44 | 45 | $builder->addDefinition($this->prefix('errorHandler')) 46 | ->setFactory(SimpleErrorHandler::class) 47 | ->setType(IErrorHandler::class) 48 | ->addSetup('setCatchException', [$catchException]); 49 | 50 | $builder->addDefinition($this->prefix('application')) 51 | ->setType(IApplication::class) 52 | ->setFactory(Application::class); 53 | 54 | $builder->addDefinition($this->prefix('router')) 55 | ->setType(IRouter::class) 56 | ->setFactory(SimpleRouter::class); 57 | 58 | $builder->addDefinition($this->prefix('handler')) 59 | ->setType(IHandler::class) 60 | ->setFactory(ServiceHandler::class); 61 | 62 | $builder->addDefinition($this->prefix('schema')) 63 | ->setFactory(Schema::class); 64 | } 65 | 66 | public function beforePluginCompile(): void 67 | { 68 | $builder = $this->getContainerBuilder(); 69 | 70 | $errorHandlerDefinition = $builder->getDefinition($this->prefix('errorHandler')); 71 | assert($errorHandlerDefinition instanceof ServiceDefinition); 72 | 73 | // Set error handler to PsrErrorHandler if logger is available and user didn't change logger himself 74 | if ($errorHandlerDefinition->getFactory()->getEntity() === SimpleErrorHandler::class) { 75 | try { 76 | $loggerDefinition = $builder->getDefinitionByType(LoggerInterface::class); 77 | $errorHandlerDefinition->setFactory(PsrLogErrorHandler::class, [$loggerDefinition]); 78 | } catch (MissingServiceException $exception) { 79 | // No need to handle 80 | } 81 | } 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/Core/DI/Plugin/Plugin.php: -------------------------------------------------------------------------------- 1 | compiler = $compiler; 26 | } 27 | 28 | abstract public static function getName(): string; 29 | 30 | /** 31 | * Process and validate config 32 | * 33 | * @param mixed[] $config 34 | */ 35 | public function setupPlugin(array $config = []): void 36 | { 37 | $name = $this->compiler->getExtension()->getName() . ' > plugins > ' . static::class; 38 | $this->setupConfig($this->getConfigSchema(), $config, $name); 39 | } 40 | 41 | public function loadPluginConfiguration(): void 42 | { 43 | // Override in child 44 | } 45 | 46 | public function beforePluginCompile(): void 47 | { 48 | // Override in child 49 | } 50 | 51 | public function afterPluginCompile(ClassType $class): void 52 | { 53 | // Override in child 54 | } 55 | 56 | protected function getConfigSchema(): Schema 57 | { 58 | return Expect::structure([]); 59 | } 60 | 61 | /** 62 | * @param mixed[] $config 63 | */ 64 | protected function setupConfig(Schema $schema, array $config, string $name): void 65 | { 66 | $processor = new Processor(); 67 | $processor->onNewContext[] = static function (Context $context) use ($name): void { 68 | $context->path = [$name]; 69 | }; 70 | try { 71 | $this->config = $processor->process($schema, $config); 72 | } catch (ValidationException $exception) { 73 | throw new InvalidConfigurationException($exception->getMessage()); 74 | } 75 | } 76 | 77 | protected function prefix(string $id): string 78 | { 79 | return $this->compiler->getExtension()->prefix(static::getName() . '.' . $id); 80 | } 81 | 82 | protected function extensionPrefix(string $id): string 83 | { 84 | return $this->compiler->getExtension()->prefix($id); 85 | } 86 | 87 | protected function getContainerBuilder(): ContainerBuilder 88 | { 89 | return $this->compiler->getExtension()->getContainerBuilder(); 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /src/Core/DI/Plugin/PluginCompiler.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 17 | $this->extension = $extension; 18 | } 19 | 20 | public function getExtension(): ApiExtension 21 | { 22 | return $this->extension; 23 | } 24 | 25 | public function getPlugin(string $name): ?Plugin 26 | { 27 | $plugins = $this->manager->getPlugins(); 28 | 29 | return $plugins[$name]['inst'] ?? null; 30 | } 31 | 32 | public function getPluginByType(string $class): ?Plugin 33 | { 34 | foreach ($this->manager->getPlugins() as $plugin) { 35 | if (get_class($plugin['inst']) === $class) { 36 | return $plugin['inst']; 37 | } 38 | } 39 | 40 | return null; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/Core/DI/Plugin/PluginManager.php: -------------------------------------------------------------------------------- 1 | */ 15 | private array $plugins = []; 16 | 17 | public function __construct(ApiExtension $extension) 18 | { 19 | $this->compiler = new PluginCompiler($this, $extension); 20 | } 21 | 22 | /** 23 | * @param mixed[] $config 24 | */ 25 | public function registerPlugin(Plugin $plugin, array $config = []): Plugin 26 | { 27 | // Register plugin 28 | $this->plugins[$plugin::getName()] = [ 29 | 'inst' => $plugin, 30 | 'config' => $config, 31 | ]; 32 | 33 | return $plugin; 34 | } 35 | 36 | /** 37 | * @param mixed[] $plugins 38 | */ 39 | public function loadPlugins(array $plugins): void 40 | { 41 | foreach ($plugins as $class => $config) { 42 | $this->loadPlugin($class, (array) $config); 43 | } 44 | } 45 | 46 | /** 47 | * @param mixed[] $config 48 | */ 49 | public function loadPlugin(string $class, array $config = []): void 50 | { 51 | if (!is_subclass_of($class, Plugin::class)) { 52 | throw new InvalidStateException(sprintf('Plugin class "%s" is not subclass of "%s"', $class, Plugin::class)); 53 | } 54 | 55 | /** @var Plugin $plugin */ 56 | $plugin = new $class($this->compiler); 57 | 58 | // Register plugin 59 | $this->registerPlugin($plugin, $config); 60 | } 61 | 62 | /** 63 | * @return array 64 | */ 65 | public function getPlugins(): array 66 | { 67 | return $this->plugins; 68 | } 69 | 70 | /** 71 | * Register services from all plugins 72 | */ 73 | public function loadConfigurations(): void 74 | { 75 | foreach ($this->plugins as $plugin) { 76 | $plugin['inst']->setupPlugin($plugin['config']); 77 | $plugin['inst']->loadPluginConfiguration(); 78 | } 79 | } 80 | 81 | /** 82 | * Register services from all plugins 83 | */ 84 | public function beforeCompiles(): void 85 | { 86 | foreach ($this->plugins as $plugin) { 87 | $plugin['inst']->beforePluginCompile(); 88 | } 89 | } 90 | 91 | /** 92 | * Decorate PHP code 93 | */ 94 | public function afterCompiles(ClassType $class): void 95 | { 96 | foreach ($this->plugins as $plugin) { 97 | $plugin['inst']->afterPluginCompile($class); 98 | } 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /src/Core/Decorator/DecoratorManager.php: -------------------------------------------------------------------------------- 1 | requestDecorators[] = $decorator; 27 | 28 | return $this; 29 | } 30 | 31 | public function decorateRequest(ApiRequest $request, ApiResponse $response): ApiRequest 32 | { 33 | foreach ($this->requestDecorators as $decorator) { 34 | $request = $decorator->decorateRequest($request, $response); 35 | } 36 | 37 | return $request; 38 | } 39 | 40 | /** 41 | * @return static 42 | */ 43 | public function addResponseDecorator(IResponseDecorator $decorator): self 44 | { 45 | $this->responseDecorators[] = $decorator; 46 | 47 | return $this; 48 | } 49 | 50 | public function decorateResponse(ApiRequest $request, ApiResponse $response): ApiResponse 51 | { 52 | foreach ($this->responseDecorators as $decorator) { 53 | $response = $decorator->decorateResponse($request, $response); 54 | } 55 | 56 | return $response; 57 | } 58 | 59 | /** 60 | * @return static 61 | */ 62 | public function addErrorDecorator(IErrorDecorator $decorator): self 63 | { 64 | $this->errorDecorators[] = $decorator; 65 | 66 | return $this; 67 | } 68 | 69 | public function decorateError(ApiRequest $request, ApiResponse $response, ApiException $error): ?ApiResponse 70 | { 71 | // If there is no exception handler defined so return null (and exception will be thrown in DecoratedDispatcher) 72 | if ($this->errorDecorators === []) { 73 | return null; 74 | } 75 | 76 | foreach ($this->errorDecorators as $decorator) { 77 | $response = $decorator->decorateError($request, $response, $error); 78 | } 79 | 80 | return $response; 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/Core/Decorator/IErrorDecorator.php: -------------------------------------------------------------------------------- 1 | mapping = $mapping; 17 | } 18 | 19 | public function decorateRequest(ApiRequest $request, ApiResponse $response): ApiRequest 20 | { 21 | return $this->mapping->map($request, $response); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/Decorator/RequestParametersDecorator.php: -------------------------------------------------------------------------------- 1 | mapping = $mapping; 17 | } 18 | 19 | public function decorateRequest(ApiRequest $request, ApiResponse $response): ApiRequest 20 | { 21 | return $this->mapping->map($request, $response); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/Dispatcher/CoreDispatcher.php: -------------------------------------------------------------------------------- 1 | router = $router; 23 | $this->handler = $handler; 24 | } 25 | 26 | public function dispatch(ApiRequest $request, ApiResponse $response): ApiResponse 27 | { 28 | // Try match request to our routes 29 | $matchedRequest = $this->match($request, $response); 30 | 31 | // If there is no match route <=> endpoint, 32 | if ($matchedRequest === null) { 33 | return $this->fallback($request, $response); 34 | } 35 | 36 | // According to matched endpoint, forward to handler 37 | return $this->handle($matchedRequest, $response); 38 | } 39 | 40 | protected function match(ApiRequest $request, ApiResponse $response): ?ApiRequest 41 | { 42 | return $this->router->match($request); 43 | } 44 | 45 | protected function handle(ApiRequest $request, ApiResponse $response): ApiResponse 46 | { 47 | $response = $this->handler->handle($request, $response); 48 | 49 | // Validate if response is ResponseInterface 50 | if (!($response instanceof ResponseInterface)) { 51 | throw new InvalidStateException(sprintf('Endpoint returned response must implement "%s"', ResponseInterface::class)); 52 | } 53 | 54 | if (!($response instanceof ApiResponse)) { //TODO - deprecation warning 55 | $response = new ApiResponse($response); 56 | } 57 | 58 | return $response; 59 | } 60 | 61 | protected function fallback(ApiRequest $request, ApiResponse $response): ApiResponse 62 | { 63 | throw new ClientErrorException('No matched route by given URL', 404); 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/Core/Dispatcher/DispatchError.php: -------------------------------------------------------------------------------- 1 | error = $error; 18 | $this->request = $request; 19 | } 20 | 21 | public function getError(): Throwable 22 | { 23 | return $this->error; 24 | } 25 | 26 | public function getRequest(): ApiRequest 27 | { 28 | return $this->request; 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/Core/Dispatcher/IDispatcher.php: -------------------------------------------------------------------------------- 1 | withStatus(404) 17 | ->withHeader('Content-Type', 'application/json'); 18 | $response->getBody()->write(Json::encode(['error' => 'No matched route by given URL'])); 19 | 20 | return $response; 21 | } 22 | 23 | protected function handle(ApiRequest $request, ApiResponse $response): ApiResponse 24 | { 25 | $result = $this->handler->handle($request, $response); 26 | 27 | // Convert array and scalar into JSON 28 | // Or just pass response 29 | if (is_array($result) || is_scalar($result)) { 30 | $response = $response->withStatus(200) 31 | ->withHeader('Content-Type', 'application/json'); 32 | $response->getBody()->write(Json::encode($result)); 33 | } else { 34 | $response = $result; 35 | } 36 | 37 | // Validate if response is ResponseInterface 38 | if (!($response instanceof ResponseInterface)) { 39 | throw new InvalidStateException(sprintf('Endpoint returned response must implement "%s"', ResponseInterface::class)); 40 | } 41 | 42 | if (!($response instanceof ApiResponse)) { //TODO - deprecation warning 43 | $response = new ApiResponse($response); 44 | } 45 | 46 | return $response; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/Core/ErrorHandler/IErrorHandler.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 21 | } 22 | 23 | public function handle(DispatchError $dispatchError): ApiResponse 24 | { 25 | $error = $dispatchError->getError(); 26 | 27 | if ($error instanceof SnapshotException) { 28 | $error = $error->getPrevious(); 29 | } 30 | 31 | // Log exception only if it's not designed to be displayed 32 | if (!$error instanceof ApiException) { 33 | $this->logger->error($error->getMessage(), ['exception' => $error]); 34 | } 35 | 36 | // Also log original exception if any 37 | if ($error instanceof ApiException && ($previous = $error->getPrevious()) !== null) { 38 | // Server error is expected to contain a real error while client error can contain just information, why client request failed 39 | $level = $error instanceof ServerErrorException ? LogLevel::ERROR : LogLevel::DEBUG; 40 | $this->logger->log($level, $previous->getMessage(), ['exception' => $previous]); 41 | } 42 | 43 | return parent::handle($dispatchError); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/Core/ErrorHandler/SimpleErrorHandler.php: -------------------------------------------------------------------------------- 1 | catchException = $catchException; 23 | } 24 | 25 | public function handle(DispatchError $dispatchError): ApiResponse 26 | { 27 | $error = $dispatchError->getError(); 28 | 29 | // Rethrow error if it should not be catch (debug only) 30 | if (!$this->catchException) { 31 | // Unwrap exception from snapshot 32 | if ($error instanceof SnapshotException) { 33 | throw $error->getPrevious(); 34 | } 35 | 36 | throw $error; 37 | } 38 | 39 | // Response is inside snapshot, return it 40 | if ($error instanceof SnapshotException) { 41 | return $error->getResponse(); 42 | } 43 | 44 | // No response available, create new from error 45 | return $this->createResponseFromError($error); 46 | } 47 | 48 | protected function createResponseFromError(Throwable $error): ApiResponse 49 | { 50 | $code = $error instanceof ApiException ? $error->getCode() : 500; 51 | 52 | $data = [ 53 | 'status' => 'error', 54 | 'code' => $code, 55 | 'message' => $error instanceof ApiException ? $error->getMessage() : ServerErrorException::$defaultMessage, 56 | ]; 57 | 58 | if ($error instanceof ApiException && ($context = $error->getContext()) !== null) { 59 | $data['context'] = $context; 60 | } 61 | 62 | $body = Utils::streamFor(Json::encode($data)); 63 | 64 | $response = new ApiResponse(new Response()); 65 | 66 | return $response 67 | ->withStatus($code) 68 | ->withHeader('Content-Type', 'application/json') 69 | ->withBody($body); 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/Core/Exception/Api/ClientErrorException.php: -------------------------------------------------------------------------------- 1 | 499) { 20 | throw new InvalidArgumentException(sprintf('%s code could be only in range from 400 to 499', static::class)); 21 | } 22 | 23 | parent::__construct($message !== '' ? $message : static::$defaultMessage, $code, $previous, $context); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/Core/Exception/Api/MessageException.php: -------------------------------------------------------------------------------- 1 | withTypedContext('message', $message); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Core/Exception/Api/ServerErrorException.php: -------------------------------------------------------------------------------- 1 | 599) { 20 | throw new InvalidArgumentException(sprintf('%s code could be only in range from 500 to 599', static::class)); 21 | } 22 | 23 | parent::__construct($message !== '' ? $message : static::$defaultMessage, $code, $previous); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/Core/Exception/Api/ValidationException.php: -------------------------------------------------------------------------------- 1 | withTypedContext('validation', $fields); 26 | } 27 | 28 | /** 29 | * @param mixed[] $fields 30 | * @return static 31 | */ 32 | public function withFormFields(array $fields): static 33 | { 34 | foreach ($fields as $key => $value) { 35 | if (is_numeric($key)) { 36 | throw new InvalidArgumentException(sprintf('Field key must be string "%s" give.', $key)); 37 | } 38 | 39 | if (!is_array($value)) { 40 | throw new InvalidArgumentException(sprintf('Field values must be array "%s" give.', $value)); 41 | } 42 | } 43 | 44 | return $this->withTypedContext('validation', $fields); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/Core/Exception/ApiException.php: -------------------------------------------------------------------------------- 1 | context = $context; 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/Core/Exception/AttributeException.php: -------------------------------------------------------------------------------- 1 | code = $code; 31 | 32 | return $this; 33 | } 34 | 35 | /** 36 | * @param string|string[] $message 37 | * @return static 38 | */ 39 | public function withMessage(string|array $message): static 40 | { 41 | $this->message = $message; 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * @return static 48 | */ 49 | public function withPrevious(Throwable $exception): static 50 | { 51 | // @phpcs:ignore SlevomatCodingStandard.Exceptions.ReferenceThrowableOnly.ReferencedGeneralException 52 | $reflection = new ReflectionClass(Exception::class); 53 | $property = $reflection->getProperty('previous'); 54 | $property->setAccessible(true); 55 | $property->setValue($this, $exception); 56 | $property->setAccessible(false); 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * @return static 63 | */ 64 | public function withContext(mixed $context): static 65 | { 66 | $this->context = $context; 67 | 68 | return $this; 69 | } 70 | 71 | /** 72 | * @return static 73 | */ 74 | public function withTypedContext(string $type, mixed $context): static 75 | { 76 | $this->context = [$type => $context]; 77 | 78 | return $this; 79 | } 80 | 81 | public function getContext(): mixed 82 | { 83 | return $this->context; 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/Core/Exception/Logical/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | controller = $controller; 22 | 23 | return $this; 24 | } 25 | 26 | /** 27 | * @return static 28 | */ 29 | public function withMethod(Method $method): self 30 | { 31 | $this->method = $method; 32 | 33 | return $this; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/Core/Exception/Logical/InvalidStateException.php: -------------------------------------------------------------------------------- 1 | response = $response; 18 | } 19 | 20 | public function getResponse(): ApiResponse 21 | { 22 | return $this->response; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Core/Exception/Runtime/InvalidArgumentTypeException.php: -------------------------------------------------------------------------------- 1 | type = $type; 25 | $this->description = $description; 26 | } 27 | 28 | public function getType(): string 29 | { 30 | return $this->type; 31 | } 32 | 33 | public function getDescription(): ?string 34 | { 35 | return $this->description; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/Core/Exception/Runtime/SnapshotException.php: -------------------------------------------------------------------------------- 1 | getMessage(), is_string($exception->getCode()) ? -1 : $exception->getCode(), $exception); 23 | 24 | $this->request = $request; 25 | $this->response = $response; 26 | } 27 | 28 | public function getRequest(): ApiRequest 29 | { 30 | return $this->request; 31 | } 32 | 33 | public function getResponse(): ApiResponse 34 | { 35 | return $this->response; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/Core/Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | service = $service; 20 | $this->method = $method; 21 | } 22 | 23 | public function getService(): IController 24 | { 25 | return $this->service; 26 | } 27 | 28 | public function getMethod(): string 29 | { 30 | return $this->method; 31 | } 32 | 33 | public function __invoke(ServerRequestInterface $request, ResponseInterface $response): mixed 34 | { 35 | return call_user_func(Helpers::callback([$this->service, $this->method]), $request, $response); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/Core/Handler/ServiceHandler.php: -------------------------------------------------------------------------------- 1 | container = $container; 22 | } 23 | 24 | public function handle(ApiRequest $request, ApiResponse $response): mixed 25 | { 26 | // Create and trigger callback 27 | $endpoint = $this->getEndpoint($request); 28 | $callback = $this->createCallback($endpoint); 29 | 30 | return $callback($request, $response); 31 | } 32 | 33 | protected function createCallback(Endpoint $endpoint): ServiceCallback 34 | { 35 | // Find handler in DI container by class 36 | $service = $this->getService($endpoint); 37 | $method = $endpoint->getHandler()->getMethod(); 38 | 39 | // Create callback 40 | return new ServiceCallback($service, $method); 41 | } 42 | 43 | protected function getEndpoint(ApiRequest $request): Endpoint 44 | { 45 | /** @var Endpoint|null $endpoint */ 46 | $endpoint = $request->getAttribute(RequestAttributes::ATTR_ENDPOINT); 47 | 48 | // Validate that we have an endpoint 49 | if ($endpoint === null) { 50 | throw new InvalidStateException(sprintf('Attribute "%s" is required', RequestAttributes::ATTR_ENDPOINT)); 51 | } 52 | 53 | return $endpoint; 54 | } 55 | 56 | protected function getService(Endpoint $endpoint): IController 57 | { 58 | $class = $endpoint->getHandler()->getClass(); 59 | $service = $this->container->getByType($class); 60 | 61 | if (!($service instanceof IController)) { 62 | throw new InvalidArgumentException(sprintf('Controller "%s" must implement "%s"', $class, IController::class)); 63 | } 64 | 65 | return $service; 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/Core/Http/ApiRequest.php: -------------------------------------------------------------------------------- 1 | getAttribute(RequestAttributes::ATTR_PARAMETERS, [])); 17 | } 18 | 19 | public function getParameter(string $name, mixed $default = null): mixed 20 | { 21 | return $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, [])[$name] ?? $default; 22 | } 23 | 24 | public function getParameters(): mixed 25 | { 26 | return $this->getAttribute(RequestAttributes::ATTR_PARAMETERS, []); 27 | } 28 | 29 | public function getEntity(mixed $default = null): mixed 30 | { 31 | $entity = $this->getAttribute(RequestAttributes::ATTR_REQUEST_ENTITY, null); 32 | 33 | if ($entity === null) { 34 | if (func_num_args() < 1) { 35 | throw new InvalidStateException('No request entity found'); 36 | } 37 | 38 | return $default; 39 | } 40 | 41 | return $entity; 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/Core/Http/RequestAttributes.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function getIterator(): ArrayIterator 20 | { 21 | return new ArrayIterator($this->toArray()); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/Mapping/Request/BasicEntity.php: -------------------------------------------------------------------------------- 1 | getProperties(); 23 | } 24 | 25 | /** 26 | * @return BasicEntity|null 27 | */ 28 | public function fromRequest(ApiRequest $request): ?IRequestEntity 29 | { 30 | if (in_array($request->getMethod(), [Endpoint::METHOD_POST, Endpoint::METHOD_PUT, Endpoint::METHOD_PATCH], true)) { 31 | return $this->fromBodyRequest($request); 32 | } 33 | 34 | if (in_array($request->getMethod(), [Endpoint::METHOD_GET, Endpoint::METHOD_DELETE, Endpoint::METHOD_HEAD], true)) { 35 | return $this->fromGetRequest($request); 36 | } 37 | 38 | return null; 39 | } 40 | 41 | /** 42 | * @param array $data 43 | * @return static 44 | */ 45 | public function factory(array $data): self 46 | { 47 | $inst = new static(); 48 | 49 | // Fill properties with real data 50 | $properties = $inst->getRequestProperties(); 51 | foreach ($properties as $property) { 52 | if (!array_key_exists($property['name'], $data)) { 53 | continue; 54 | } 55 | 56 | $value = $data[$property['name']]; 57 | 58 | // Normalize & convert value (only not null values) 59 | if ($value !== null) { 60 | $value = $this->normalize($property['name'], $value); 61 | } 62 | 63 | // Fill single property 64 | try { 65 | $inst->{$property['name']} = $value; 66 | } catch (TypeError) { 67 | // do nothing, entity will be invalid if something is missing and ValidationException will be thrown 68 | } 69 | } 70 | 71 | return $inst; 72 | } 73 | 74 | protected function normalize(string $property, mixed $value): mixed 75 | { 76 | return $value; 77 | } 78 | 79 | /** 80 | * @return static 81 | */ 82 | protected function fromBodyRequest(ApiRequest $request): self 83 | { 84 | try { 85 | $body = (array) $request->getJsonBodyCopy(true); 86 | } catch (JsonException $ex) { 87 | throw new ClientErrorException('Invalid json data', 400, $ex); 88 | } 89 | 90 | return $this->factory($body); 91 | } 92 | 93 | /** 94 | * @return static 95 | */ 96 | protected function fromGetRequest(ApiRequest $request): self 97 | { 98 | return $this->factory($request->getQueryParams()); 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /src/Core/Mapping/Request/IRequestEntity.php: -------------------------------------------------------------------------------- 1 | validator = $validator; 22 | } 23 | 24 | public function map(ApiRequest $request, ApiResponse $response): ApiRequest 25 | { 26 | /** @var Endpoint|null $endpoint */ 27 | $endpoint = $request->getAttribute(RequestAttributes::ATTR_ENDPOINT); 28 | 29 | // Validate that we have an endpoint 30 | if ($endpoint === null) { 31 | throw new InvalidStateException(sprintf('Attribute "%s" is required', RequestAttributes::ATTR_ENDPOINT)); 32 | } 33 | 34 | $requestBody = $endpoint->getRequestBody(); 35 | // If there's no request mapper, then skip it 36 | if ($requestBody === null) { 37 | return $request; 38 | } 39 | 40 | // Create entity 41 | $entity = $this->createEntity($requestBody, $request); 42 | 43 | if ($entity !== null) { 44 | $request = $request->withAttribute(RequestAttributes::ATTR_REQUEST_ENTITY, $entity); 45 | } 46 | 47 | return $request; 48 | } 49 | 50 | protected function createEntity(EndpointRequestBody $requestBody, ApiRequest $request): object|null 51 | { 52 | $entityClass = $requestBody->getEntity(); 53 | 54 | if ($entityClass === null) { 55 | return null; 56 | } 57 | 58 | $entity = new $entityClass(); 59 | 60 | // Allow modify entity in extended class 61 | $entity = $this->modify($entity, $request); 62 | 63 | if ($entity === null) { 64 | return null; 65 | } 66 | 67 | // Try to validate entity only if its enabled 68 | if ($requestBody->isValidation()) { 69 | $this->validate($entity); 70 | } 71 | 72 | return $entity; 73 | } 74 | 75 | protected function modify(object $entity, ApiRequest $request): object|null 76 | { 77 | if ($entity instanceof IRequestEntity) { 78 | return $entity->fromRequest($request); 79 | } 80 | 81 | return $entity; 82 | } 83 | 84 | /** 85 | * @phpcsSuppress SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingParameterTypeHint 86 | */ 87 | protected function validate(object $entity): void 88 | { 89 | if ($this->validator === null) { 90 | return; 91 | } 92 | 93 | $this->validator->validate($entity); 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/Core/Mapping/Response/AbstractEntity.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function getIterator(): ArrayIterator 20 | { 21 | return new ArrayIterator($this->toArray()); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/Mapping/Response/BasicEntity.php: -------------------------------------------------------------------------------- 1 | getProperties(); 18 | } 19 | 20 | /** 21 | * @return mixed[] 22 | */ 23 | public function toResponse(): array 24 | { 25 | return $this->toArray(); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/Core/Mapping/Response/IResponseEntity.php: -------------------------------------------------------------------------------- 1 | > */ 11 | protected array $properties = []; 12 | 13 | /** 14 | * @return array> 15 | */ 16 | public function getProperties(): array 17 | { 18 | if ($this->properties === []) { 19 | $properties = []; 20 | $rf = new ReflectionObject($this); 21 | $class = static::class; 22 | 23 | $defaultProperties = $rf->getDefaultProperties(); 24 | foreach ($rf->getProperties() as $property) { 25 | // If property is not from the latest child, then skip it. 26 | if ($property->getDeclaringClass()->getName() !== $class) { 27 | continue; 28 | } 29 | 30 | // If property is not public, then skip it. 31 | if (!$property->isPublic()) { 32 | continue; 33 | } 34 | 35 | $name = $property->getName(); 36 | $properties[$name] = [ 37 | 'name' => $name, 38 | 'type' => $property->isInitialized($this) ? $property->getValue($this) : null, 39 | 'defaultValue' => $defaultProperties[$name] ?? null, 40 | ]; 41 | } 42 | 43 | $this->properties = $properties; 44 | } 45 | 46 | return $this->properties; 47 | } 48 | 49 | /** 50 | * @return mixed[] 51 | */ 52 | public function toArray(): array 53 | { 54 | $data = []; 55 | $properties = $this->getProperties(); 56 | 57 | foreach ($properties as $property) { 58 | if (!isset($this->{$property['name']})) { 59 | continue; 60 | } 61 | 62 | $data[$property['name']] = $this->{$property['name']}; 63 | } 64 | 65 | return $data; 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/Core/Mapping/Validator/BasicValidator.php: -------------------------------------------------------------------------------- 1 | validateProperties($entity); 25 | 26 | if ($violations !== []) { 27 | $fields = []; 28 | foreach ($violations as $property => $messages) { 29 | $fields[$property] = count($messages) > 1 ? $messages : $messages[0]; 30 | } 31 | 32 | throw ValidationException::create() 33 | ->withFields($fields); 34 | } 35 | } 36 | 37 | /** 38 | * @return string[][] 39 | */ 40 | protected function validateProperties(BasicEntity $entity): array 41 | { 42 | $violations = []; 43 | $properties = $entity->getProperties(); 44 | $rf = new ReflectionObject($entity); 45 | 46 | foreach (array_keys($properties) as $propertyName) { 47 | $propertyRf = $rf->getProperty($propertyName); 48 | $doc = (string) $propertyRf->getDocComment(); 49 | 50 | if (str_contains($doc, '@required') && $entity->{$propertyName} === null) { 51 | $violations[$propertyName][] = 'This value should not be null.'; 52 | } 53 | } 54 | 55 | return $violations; 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/Core/Mapping/Validator/IEntityValidator.php: -------------------------------------------------------------------------------- 1 | reader = $reader; 29 | AnnotationReader::addGlobalIgnoredName('mapping'); 30 | } 31 | 32 | public function setConstraintValidatorFactory(ConstraintValidatorFactoryInterface $constraintValidatorFactory): void 33 | { 34 | $this->constraintValidatorFactory = $constraintValidatorFactory; 35 | } 36 | 37 | public function setTranslator(TranslatorInterface $translator): void 38 | { 39 | $this->translator = $translator; 40 | } 41 | 42 | public function setTranslationDomain(string $translationDomain): void 43 | { 44 | $this->translationDomain = $translationDomain; 45 | } 46 | 47 | public function setGroups(array $groups): void 48 | { 49 | $this->groups = $groups; 50 | } 51 | 52 | /** 53 | * @throws ValidationException 54 | * @phpcsSuppress SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingParameterTypeHint 55 | */ 56 | public function validate(object $entity): void 57 | { 58 | $validatorBuilder = Validation::createValidatorBuilder(); 59 | $validatorBuilder->enableAttributeMapping(); 60 | 61 | if (method_exists($validatorBuilder, 'setDoctrineAnnotationReader')) { 62 | $validatorBuilder->setDoctrineAnnotationReader($this->reader); 63 | } 64 | 65 | if ($this->constraintValidatorFactory !== null) { 66 | $validatorBuilder->setConstraintValidatorFactory($this->constraintValidatorFactory); 67 | } 68 | 69 | if ($this->translator !== null) { 70 | $validatorBuilder->setTranslator($this->translator); 71 | $validatorBuilder->setTranslationDomain($this->translationDomain); 72 | } 73 | 74 | $validator = $validatorBuilder->getValidator(); 75 | 76 | /** @var ConstraintViolationListInterface $violations */ 77 | $violations = $validator->validate($entity, null, $this->groups); 78 | 79 | if (count($violations) > 0) { 80 | $fields = []; 81 | foreach ($violations as $violation) { 82 | $fields[$violation->getPropertyPath()][] = $violation->getMessage(); 83 | } 84 | 85 | throw ValidationException::create() 86 | ->withFields($fields); 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/Core/Router/IRouter.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 21 | } 22 | 23 | public function match(ApiRequest $request): ?ApiRequest 24 | { 25 | $endpoints = $this->schema->getEndpoints(); 26 | 27 | $exception = null; 28 | $matched = null; 29 | // Iterate over all endpoints 30 | foreach ($endpoints as $endpoint) { 31 | try { 32 | $matched = $this->matchEndpoint($endpoint, $request); 33 | } catch (ClientErrorException $exception) { 34 | // Don't throw exception unless we know there is no endpoint with same mask which support requested http method 35 | } 36 | 37 | // Skip if endpoint is not matched 38 | if ($matched === null) { 39 | continue; 40 | } 41 | 42 | // If matched is not null, returns given ServerRequestInterface 43 | // with all parsed arguments and data, 44 | // also append given Endpoint 45 | $matched = $matched 46 | ->withAttribute(RequestAttributes::ATTR_ENDPOINT, $endpoint); 47 | 48 | return $matched; 49 | } 50 | 51 | if ($exception !== null) { 52 | throw $exception; 53 | } 54 | 55 | return null; 56 | } 57 | 58 | protected function matchEndpoint(Endpoint $endpoint, ApiRequest $request): ?ApiRequest 59 | { 60 | // Try match given URL (path) by build pattern 61 | $request = $this->compareUrl($endpoint, $request); 62 | 63 | // Skip unsupported HTTP method 64 | if ($request !== null && !$endpoint->hasMethod($request->getMethod())) { 65 | throw new ClientErrorException(sprintf('Method "%s" is not allowed for endpoint "%s".', $request->getMethod(), $endpoint->getMask()), 405); 66 | } 67 | 68 | return $request; 69 | } 70 | 71 | protected function compareUrl(Endpoint $endpoint, ApiRequest $request): ?ApiRequest 72 | { 73 | // Parse url from request 74 | $url = $request->getUri()->getPath(); 75 | 76 | // Url has always slash at the beginning 77 | // and no trailing slash at the end 78 | $url = '/' . trim($url, '/'); 79 | 80 | // Try to match against the pattern 81 | $match = Regex::match($url, $endpoint->getPattern()); 82 | 83 | // Skip if there's no match 84 | if ($match === null) { 85 | return null; 86 | } 87 | 88 | $parameters = []; 89 | 90 | // Fill path parameters with matched variables 91 | foreach ($endpoint->getParametersByIn(EndpointParameter::IN_PATH) as $param) { 92 | $name = $param->getName(); 93 | $parameters[$name] = $match[$name]; 94 | } 95 | 96 | // Fill query parameters with query params 97 | $queryParams = $request->getQueryParams(); 98 | foreach ($endpoint->getParametersByIn(EndpointParameter::IN_QUERY) as $param) { 99 | $name = $param->getName(); 100 | $parameters[$name] = $queryParams[$name] ?? null; 101 | } 102 | 103 | // Set attributes to request 104 | $request = $request 105 | ->withAttribute(RequestAttributes::ATTR_ROUTER, $match) 106 | ->withAttribute(RequestAttributes::ATTR_PARAMETERS, $parameters); 107 | 108 | return $request; 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/Core/Schema/Builder/Controller/Controller.php: -------------------------------------------------------------------------------- 1 | class = $class; 32 | } 33 | 34 | public function getClass(): string 35 | { 36 | return $this->class; 37 | } 38 | 39 | public function getPath(): string 40 | { 41 | return $this->path; 42 | } 43 | 44 | public function setPath(string $path): void 45 | { 46 | $this->path = $path; 47 | } 48 | 49 | /** 50 | * @return Method[] 51 | */ 52 | public function getMethods(): array 53 | { 54 | return $this->methods; 55 | } 56 | 57 | public function addMethod(string $name): Method 58 | { 59 | $method = new Method($name); 60 | $this->methods[$name] = $method; 61 | 62 | return $method; 63 | } 64 | 65 | public function getId(): ?string 66 | { 67 | return $this->id; 68 | } 69 | 70 | public function setId(?string $id): void 71 | { 72 | $this->id = $id; 73 | } 74 | 75 | /** 76 | * @return mixed[] 77 | */ 78 | public function getGroupIds(): array 79 | { 80 | return $this->groupIds; 81 | } 82 | 83 | /** 84 | * @param string[] $ids 85 | */ 86 | public function setGroupIds(array $ids): void 87 | { 88 | $this->groupIds = $ids; 89 | } 90 | 91 | public function addGroupId(string $id): void 92 | { 93 | $this->groupIds[] = $id; 94 | } 95 | 96 | /** 97 | * @return string[] 98 | */ 99 | public function getGroupPaths(): array 100 | { 101 | return $this->groupPaths; 102 | } 103 | 104 | /** 105 | * @param string[] $groupPaths 106 | */ 107 | public function setGroupPaths(array $groupPaths): void 108 | { 109 | $this->groupPaths = $groupPaths; 110 | } 111 | 112 | public function addGroupPath(string $path): void 113 | { 114 | $this->groupPaths[] = $path; 115 | } 116 | 117 | /** 118 | * @return mixed[] 119 | */ 120 | public function getTags(): array 121 | { 122 | return $this->tags; 123 | } 124 | 125 | public function addTag(string $name, mixed $value = null): void 126 | { 127 | $this->tags[$name] = $value; 128 | } 129 | 130 | /** 131 | * @param mixed[] $tags 132 | */ 133 | public function addTags(array $tags): void 134 | { 135 | foreach ($tags as $name => $value) { 136 | $this->addTag($name, $value); 137 | } 138 | } 139 | 140 | /** 141 | * @param mixed[] $openApi 142 | */ 143 | public function setOpenApi(array $openApi): void 144 | { 145 | $this->openApi = $openApi; 146 | } 147 | 148 | /** 149 | * @return mixed[] 150 | */ 151 | public function getOpenApi(): array 152 | { 153 | return $this->openApi; 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/Core/Schema/Builder/Controller/Method.php: -------------------------------------------------------------------------------- 1 | name = $name; 42 | } 43 | 44 | public function getName(): string 45 | { 46 | return $this->name; 47 | } 48 | 49 | public function getPath(): string 50 | { 51 | return $this->path; 52 | } 53 | 54 | public function setPath(string $path): void 55 | { 56 | $this->path = $path; 57 | } 58 | 59 | public function getId(): ?string 60 | { 61 | return $this->id; 62 | } 63 | 64 | public function setId(?string $id): void 65 | { 66 | $this->id = $id; 67 | } 68 | 69 | /** 70 | * @return string[] 71 | */ 72 | public function getHttpMethods(): array 73 | { 74 | return $this->httpMethods; 75 | } 76 | 77 | /** 78 | * @param string[] $httpMethods 79 | */ 80 | public function setHttpMethods(array $httpMethods): void 81 | { 82 | $this->httpMethods = $httpMethods; 83 | } 84 | 85 | public function addHttpMethod(string $method): void 86 | { 87 | $this->httpMethods[] = strtoupper($method); 88 | } 89 | 90 | /** 91 | * @param string[] $httpMethods 92 | */ 93 | public function addHttpMethods(array $httpMethods): void 94 | { 95 | foreach ($httpMethods as $httpMethod) { 96 | $this->addHttpMethod($httpMethod); 97 | } 98 | } 99 | 100 | /** 101 | * @return mixed[] 102 | */ 103 | public function getTags(): array 104 | { 105 | return $this->tags; 106 | } 107 | 108 | public function addTag(string $name, mixed $value = null): void 109 | { 110 | $this->tags[$name] = $value; 111 | } 112 | 113 | /** 114 | * @param mixed[] $tags 115 | */ 116 | public function addTags(array $tags): void 117 | { 118 | foreach ($tags as $name => $value) { 119 | $this->addTag($name, $value); 120 | } 121 | } 122 | 123 | public function addParameter(string $name, string $type = EndpointParameter::TYPE_STRING): EndpointParameter 124 | { 125 | $parameter = new EndpointParameter($name, $type); 126 | $this->parameters[$name] = $parameter; 127 | 128 | return $parameter; 129 | } 130 | 131 | public function getRequestBody(): ?EndpointRequestBody 132 | { 133 | return $this->requestBody; 134 | } 135 | 136 | public function setRequestBody(?EndpointRequestBody $requestBody): void 137 | { 138 | $this->requestBody = $requestBody; 139 | } 140 | 141 | public function addResponse(string $code, string $description): EndpointResponse 142 | { 143 | $response = new EndpointResponse($code, $description); 144 | $this->responses[$code] = $response; 145 | 146 | return $response; 147 | } 148 | 149 | public function hasParameter(string $name): bool 150 | { 151 | return isset($this->parameters[$name]); 152 | } 153 | 154 | public function hasResponse(string $code): bool 155 | { 156 | return isset($this->responses[$code]); 157 | } 158 | 159 | /** 160 | * @return EndpointParameter[] 161 | */ 162 | public function getParameters(): array 163 | { 164 | return $this->parameters; 165 | } 166 | 167 | /** 168 | * @return EndpointResponse[] 169 | */ 170 | public function getResponses(): array 171 | { 172 | return $this->responses; 173 | } 174 | 175 | /** 176 | * @param mixed[] $openApi 177 | */ 178 | public function setOpenApi(array $openApi): void 179 | { 180 | $this->openApi = $openApi; 181 | } 182 | 183 | /** 184 | * @return mixed[] 185 | */ 186 | public function getOpenApi(): array 187 | { 188 | return $this->openApi; 189 | } 190 | 191 | public function addNegotiation(string $suffix): EndpointNegotiation 192 | { 193 | $negotiation = new EndpointNegotiation($suffix); 194 | $this->negotiations[] = $negotiation; 195 | 196 | return $negotiation; 197 | } 198 | 199 | /** 200 | * @return EndpointNegotiation[] 201 | */ 202 | public function getNegotiations(): array 203 | { 204 | return $this->negotiations; 205 | } 206 | 207 | } 208 | -------------------------------------------------------------------------------- /src/Core/Schema/EndpointHandler.php: -------------------------------------------------------------------------------- 1 | class = $class; 19 | $this->method = $method; 20 | } 21 | 22 | /** 23 | * @return class-string 24 | */ 25 | public function getClass(): string 26 | { 27 | return $this->class; 28 | } 29 | 30 | public function getMethod(): string 31 | { 32 | return $this->method; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Core/Schema/EndpointNegotiation.php: -------------------------------------------------------------------------------- 1 | suffix = $suffix; 17 | } 18 | 19 | public function getSuffix(): string 20 | { 21 | return $this->suffix; 22 | } 23 | 24 | public function isDefault(): bool 25 | { 26 | return $this->default; 27 | } 28 | 29 | public function setDefault(bool $default): void 30 | { 31 | $this->default = $default; 32 | } 33 | 34 | public function getRenderer(): ?string 35 | { 36 | return $this->renderer; 37 | } 38 | 39 | public function setRenderer(?string $renderer): void 40 | { 41 | $this->renderer = $renderer; 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/Core/Schema/EndpointParameter.php: -------------------------------------------------------------------------------- 1 | name = $name; 55 | $this->type = $type; 56 | } 57 | 58 | public function getName(): string 59 | { 60 | return $this->name; 61 | } 62 | 63 | public function getType(): string 64 | { 65 | return $this->type; 66 | } 67 | 68 | public function getSchemaType(): string 69 | { 70 | switch ($this->type) { 71 | case self::TYPE_STRING: 72 | case self::TYPE_FLOAT: 73 | case self::TYPE_DATETIME: 74 | return $this->type; 75 | case self::TYPE_BOOLEAN: 76 | return 'boolean'; 77 | case self::TYPE_INTEGER: 78 | return 'integer'; 79 | case self::TYPE_ENUM: 80 | return 'string'; 81 | default: 82 | // custom type 83 | return 'string'; 84 | } 85 | } 86 | 87 | public function getDescription(): ?string 88 | { 89 | return $this->description; 90 | } 91 | 92 | public function setDescription(?string $description): void 93 | { 94 | $this->description = $description; 95 | } 96 | 97 | public function getIn(): string 98 | { 99 | return $this->in; 100 | } 101 | 102 | public function setIn(string $in): void 103 | { 104 | $this->in = $in; 105 | } 106 | 107 | public function isRequired(): bool 108 | { 109 | return $this->required; 110 | } 111 | 112 | public function setRequired(bool $required): void 113 | { 114 | $this->required = $required; 115 | } 116 | 117 | public function isDeprecated(): bool 118 | { 119 | return $this->deprecated; 120 | } 121 | 122 | public function setDeprecated(bool $deprecated): void 123 | { 124 | $this->deprecated = $deprecated; 125 | } 126 | 127 | public function isAllowEmpty(): bool 128 | { 129 | return $this->allowEmpty; 130 | } 131 | 132 | public function setAllowEmpty(bool $allowEmpty): void 133 | { 134 | $this->allowEmpty = $allowEmpty; 135 | } 136 | 137 | public function getEnum(): ?array 138 | { 139 | return $this->enum; 140 | } 141 | 142 | public function setEnum(?array $enum): void 143 | { 144 | $this->enum = $enum; 145 | } 146 | 147 | } 148 | -------------------------------------------------------------------------------- /src/Core/Schema/EndpointRequestBody.php: -------------------------------------------------------------------------------- 1 | description; 19 | } 20 | 21 | public function setDescription(?string $description): void 22 | { 23 | $this->description = $description; 24 | } 25 | 26 | public function getEntity(): ?string 27 | { 28 | return $this->entity; 29 | } 30 | 31 | public function setEntity(?string $entity): void 32 | { 33 | $this->entity = $entity; 34 | } 35 | 36 | public function isRequired(): bool 37 | { 38 | return $this->required; 39 | } 40 | 41 | public function setRequired(bool $required): void 42 | { 43 | $this->required = $required; 44 | } 45 | 46 | public function isValidation(): bool 47 | { 48 | return $this->validation; 49 | } 50 | 51 | public function setValidation(bool $validation): void 52 | { 53 | $this->validation = $validation; 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/Core/Schema/EndpointResponse.php: -------------------------------------------------------------------------------- 1 | code = $code; 17 | $this->description = $description; 18 | } 19 | 20 | public function setEntity(?string $entity): void 21 | { 22 | $this->entity = $entity; 23 | } 24 | 25 | public function getDescription(): string 26 | { 27 | return $this->description; 28 | } 29 | 30 | public function getCode(): string 31 | { 32 | return $this->code; 33 | } 34 | 35 | public function getEntity(): ?string 36 | { 37 | return $this->entity; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/Core/Schema/Hierarchy/ControllerMethodPair.php: -------------------------------------------------------------------------------- 1 | controller = $controller; 18 | $this->method = $method; 19 | } 20 | 21 | public function getController(): Controller 22 | { 23 | return $this->controller; 24 | } 25 | 26 | public function getMethod(): Method 27 | { 28 | return $this->method; 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/Core/Schema/Hierarchy/HierarchicalNode.php: -------------------------------------------------------------------------------- 1 | path = $path; 19 | } 20 | 21 | public function getPath(): string 22 | { 23 | return $this->path; 24 | } 25 | 26 | public function addNode(string $path): HierarchicalNode 27 | { 28 | if (!isset($this->nodes[$path])) { 29 | $this->nodes[$path] = new HierarchicalNode($path); 30 | } 31 | 32 | return $this->nodes[$path]; 33 | } 34 | 35 | public function addEndpoint(ControllerMethodPair $endpoint): void 36 | { 37 | // Store endpoint under index with GET, POST, PATCH format 38 | $httpMethods = $endpoint->getMethod()->getHttpMethods(); 39 | sort($httpMethods); 40 | $index = implode(', ', $httpMethods); 41 | 42 | $this->endpoints[$index] = $endpoint; 43 | } 44 | 45 | /** 46 | * @return ControllerMethodPair[] 47 | */ 48 | public function getSortedEndpoints(): array 49 | { 50 | // Return endpoints sorted by HTTP method 51 | ksort($this->endpoints); 52 | 53 | return array_values($this->endpoints); 54 | } 55 | 56 | /** 57 | * @return HierarchicalNode[] 58 | */ 59 | public function getSortedNodes(): array 60 | { 61 | $staticNodes = []; 62 | $variableNodes = []; 63 | 64 | // Divide static and variable nodes 65 | foreach ($this->nodes as $node) { 66 | $path = $node->getPath(); 67 | if (str_contains($path, '{') && str_contains($path, '}')) { 68 | $variableNodes[] = $node; 69 | } else { 70 | $staticNodes[] = $node; 71 | } 72 | } 73 | 74 | // Sort static nodes from A to Z and keep empty path last 75 | uasort($staticNodes, static function (HierarchicalNode $a, HierarchicalNode $b): int { 76 | $pathA = $a->getPath(); 77 | $pathB = $b->getPath(); 78 | 79 | // Same path, don't flip 80 | if ($pathA === $pathB) { 81 | return 0; 82 | } 83 | 84 | // Path is empty, keep it last 85 | if ($pathA === '') { 86 | return 1; 87 | } 88 | 89 | // Path is empty, keep it last 90 | if ($pathB === '') { 91 | return -1; 92 | } 93 | 94 | return (strcmp($pathA, $pathB) <= -1) ? -1 : 1; 95 | }); 96 | 97 | return array_merge($staticNodes, $variableNodes); 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/Core/Schema/Hierarchy/HierarchyBuilder.php: -------------------------------------------------------------------------------- 1 | controllers = $controllers; 22 | } 23 | 24 | public function getHierarchy(): HierarchicalNode 25 | { 26 | $rootNode = $this->addNode(''); 27 | 28 | foreach ($this->controllers as $controller) { 29 | $controllerPaths = $controller->getGroupPaths(); 30 | $controllerPaths[] = $controller->getPath(); 31 | $controllerPathParts = $this->splitPathParts($controllerPaths); 32 | 33 | foreach ($controller->getMethods() as $method) { 34 | $methodPathParts = $this->splitPathParts($method->getPath()); 35 | $allPathParts = array_merge($controllerPathParts, $methodPathParts); 36 | if ($allPathParts === []) { 37 | // Full path to endpoint is just /, it's a root node 38 | $rootNode->addEndpoint(new ControllerMethodPair($controller, $method)); 39 | } else { 40 | $lastPathPartKey = array_keys($allPathParts)[count($allPathParts) - 1]; // array_key_last for php < 7.3.0 41 | 42 | $previousNode = $rootNode; 43 | foreach ($allPathParts as $key => $part) { 44 | $node = $previousNode->addNode($part); 45 | 46 | if ($key === $lastPathPartKey) { 47 | $node->addEndpoint(new ControllerMethodPair($controller, $method)); 48 | } 49 | 50 | $previousNode = $node; 51 | } 52 | } 53 | } 54 | } 55 | 56 | return $rootNode; 57 | } 58 | 59 | /** 60 | * @return ControllerMethodPair[] 61 | */ 62 | public function getSortedEndpoints(): array 63 | { 64 | return $this->getSortedEndpointsFromNode($this->getHierarchy()); 65 | } 66 | 67 | /** 68 | * Creates ['api', 'v1', 'users', '{id}'] from /api/v1/users/{id} 69 | * 70 | * @param string|string[] $paths 71 | * @return string[] 72 | */ 73 | protected function splitPathParts(string|array $paths): array 74 | { 75 | $parts = []; 76 | 77 | if (is_array($paths)) { 78 | foreach ($paths as $path) { 79 | $parts = array_merge($parts, $this->splitPathParts($path)); 80 | } 81 | } else { 82 | $parts = array_merge($parts, explode('/', $paths)); 83 | } 84 | 85 | // Remove empty indexes created during split 86 | $parts = array_filter($parts, static fn ($value): bool => $value !== ''); 87 | 88 | return $parts; 89 | } 90 | 91 | /** 92 | * Hierarchical node is representation of one path part without / (e.g. users/{id} is considered to be two nodes) 93 | */ 94 | private function addNode(string $path): HierarchicalNode 95 | { 96 | if (!isset($this->nodes[$path])) { 97 | $this->nodes[$path] = new HierarchicalNode($path); 98 | } 99 | 100 | return $this->nodes[$path]; 101 | } 102 | 103 | /** 104 | * @return ControllerMethodPair[] 105 | */ 106 | private function getSortedEndpointsFromNode(HierarchicalNode $node): array 107 | { 108 | $endpoints = []; 109 | 110 | foreach ($node->getSortedNodes() as $subnode) { 111 | $endpoints = array_merge($endpoints, $this->getSortedEndpointsFromNode($subnode)); 112 | } 113 | 114 | return array_merge($endpoints, $node->getSortedEndpoints()); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/Core/Schema/Schema.php: -------------------------------------------------------------------------------- 1 | endpoints[] = $endpoint; 14 | } 15 | 16 | /** 17 | * @return Endpoint[] 18 | */ 19 | public function getEndpoints(): array 20 | { 21 | return $this->endpoints; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/Schema/SchemaBuilder.php: -------------------------------------------------------------------------------- 1 | controllers[$class] = $controller; 17 | 18 | return $controller; 19 | } 20 | 21 | /** 22 | * @return Controller[] 23 | */ 24 | public function getControllers(): array 25 | { 26 | return $this->controllers; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/Core/Schema/SchemaInspector.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 16 | } 17 | 18 | /** 19 | * @return Endpoint[] 20 | */ 21 | public function getEndpointsByTag(string $name, ?string $value = null): array 22 | { 23 | $key = rtrim(sprintf('%s/%s', $name, (string) $value), '/'); 24 | $endpoints = $this->schema->getEndpoints(); 25 | 26 | if (!isset($this->cache[$key])) { 27 | $items = []; 28 | foreach ($endpoints as $endpoint) { 29 | // Skip if endpoint does not have a tag 30 | if (!$endpoint->hasTag($name)) { 31 | continue; 32 | } 33 | 34 | // Early skip (cause value is null => optional) 35 | if ($value === null) { 36 | $items[] = $endpoint; 37 | 38 | continue; 39 | } 40 | 41 | // Skip if value is provided and values are not matched 42 | $tagval = $endpoint->getTag($name); 43 | 44 | // If tagval is string, try to compare strings 45 | if (is_string($tagval) && $tagval !== $value) { 46 | continue; 47 | } 48 | 49 | // If tagval is array, try to find it by value 50 | if (is_array($tagval) && !in_array($value, $tagval, true)) { 51 | continue; 52 | } 53 | 54 | $items[] = $endpoint; 55 | } 56 | 57 | $this->cache[$key] = $items; 58 | } 59 | 60 | return $this->cache[$key]; 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/Core/Schema/Serialization/ArrayHydrator.php: -------------------------------------------------------------------------------- 1 | hydrateEndpoint($endpoint); 28 | $schema->addEndpoint($endpoint); 29 | } 30 | 31 | return $schema; 32 | } 33 | 34 | /** 35 | * @param mixed[] $data 36 | */ 37 | private function hydrateEndpoint(array $data): Endpoint 38 | { 39 | if (!isset($data['handler'])) { 40 | throw new InvalidStateException("Schema route 'handler' is required"); 41 | } 42 | 43 | $handler = new EndpointHandler( 44 | $data['handler']['class'], 45 | $data['handler']['method'] 46 | ); 47 | 48 | $endpoint = new Endpoint($handler); 49 | $endpoint->setMethods($data['methods']); 50 | $endpoint->setMask($data['mask']); 51 | 52 | if (isset($data['tags'])) { 53 | foreach ($data['tags'] as $name => $value) { 54 | $endpoint->addTag($name, $value); 55 | } 56 | } 57 | 58 | if (isset($data['id'])) { 59 | $endpoint->addTag(Endpoint::TAG_ID, $data['id']); 60 | } 61 | 62 | if (isset($data['attributes']['pattern'])) { 63 | $endpoint->setAttribute('pattern', $data['attributes']['pattern']); 64 | } 65 | 66 | if (isset($data['parameters'])) { 67 | foreach ($data['parameters'] as $param) { 68 | $parameter = new EndpointParameter( 69 | $param['name'], 70 | $param['type'] 71 | ); 72 | $parameter->setDescription($param['description']); 73 | $parameter->setIn($param['in']); 74 | $parameter->setRequired($param['required']); 75 | $parameter->setDeprecated($param['deprecated']); 76 | $parameter->setAllowEmpty($param['allowEmpty']); 77 | $parameter->setEnum($param['enum']); 78 | 79 | $endpoint->addParameter($parameter); 80 | } 81 | } 82 | 83 | if (isset($data['requestBody'])) { 84 | $requestData = $data['requestBody']; 85 | 86 | $request = new EndpointRequestBody(); 87 | $request->setDescription($requestData['description']); 88 | $request->setEntity($requestData['entity']); 89 | $request->setRequired($requestData['required']); 90 | $request->setValidation($requestData['validation']); 91 | 92 | $endpoint->setRequestBody($request); 93 | } 94 | 95 | if (isset($data['responses'])) { 96 | foreach ($data['responses'] as $res) { 97 | $response = new EndpointResponse( 98 | $res['code'], 99 | $res['description'] 100 | ); 101 | if (isset($res['entity'])) { 102 | $response->setEntity($res['entity']); 103 | } 104 | 105 | $endpoint->addResponse($response); 106 | } 107 | } 108 | 109 | if (isset($data['openApi'])) { 110 | $endpoint->setOpenApi($data['openApi']); 111 | } 112 | 113 | if (isset($data['negotiations'])) { 114 | foreach ($data['negotiations'] as $nego) { 115 | $negotiation = new EndpointNegotiation($nego['suffix']); 116 | $negotiation->setDefault($nego['default']); 117 | $negotiation->setRenderer($nego['renderer']); 118 | 119 | $endpoint->addNegotiation($negotiation); 120 | } 121 | } 122 | 123 | return $endpoint; 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /src/Core/Schema/Serialization/IDecorator.php: -------------------------------------------------------------------------------- 1 | validateSlashes($builder); 15 | $this->validateRegex($builder); 16 | } 17 | 18 | protected function validateSlashes(SchemaBuilder $builder): void 19 | { 20 | $controllers = $builder->getControllers(); 21 | 22 | foreach ($controllers as $controller) { 23 | $path = $controller->getPath(); 24 | 25 | if ($path === '') { 26 | throw new InvalidSchemaException( 27 | sprintf('@Path in "%s" must be set.', $controller->getClass()) 28 | ); 29 | } 30 | 31 | if ($path === '/') { 32 | continue; 33 | } 34 | 35 | // MUST: Starts with slash (/) 36 | if (substr($path, 0, 1) !== '/') { 37 | throw new InvalidSchemaException( 38 | sprintf('@Path "%s" in "%s" must starts with "/" (slash).', $path, $controller->getClass()) 39 | ); 40 | } 41 | 42 | // MUST NOT: Ends with slash (/) 43 | if (substr($path, -1, 1) === '/') { 44 | throw new InvalidSchemaException( 45 | sprintf('@Path "%s" in "%s" must not ends with "/" (slash).', $path, $controller->getClass()) 46 | ); 47 | } 48 | } 49 | } 50 | 51 | protected function validateRegex(SchemaBuilder $builder): void 52 | { 53 | $controllers = $builder->getControllers(); 54 | 55 | foreach ($controllers as $controller) { 56 | $path = $controller->getPath(); 57 | 58 | // Allowed characters: 59 | // -> a-z 60 | // -> A-Z 61 | // -> 0-9 62 | // -> -_/ 63 | $match = Regex::match($path, '#([^a-zA-Z0-9\-_/]+)#'); 64 | 65 | if ($match !== null) { 66 | throw new InvalidSchemaException( 67 | sprintf( 68 | '@Path "%s" in "%s" contains illegal characters "%s". Allowed characters are only [a-zA-Z0-9-_/].', 69 | $path, 70 | $controller->getClass(), 71 | $match[1] 72 | ) 73 | ); 74 | } 75 | } 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/Core/Schema/Validation/ControllerValidation.php: -------------------------------------------------------------------------------- 1 | validateInterface($builder); 15 | } 16 | 17 | protected function validateInterface(SchemaBuilder $builder): void 18 | { 19 | $controllers = $builder->getControllers(); 20 | 21 | foreach ($controllers as $controller) { 22 | $class = $controller->getClass(); 23 | 24 | if (!is_subclass_of($class, IController::class)) { 25 | throw new InvalidSchemaException(sprintf('Controller "%s" must implement "%s"', $class, IController::class)); 26 | } 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/Core/Schema/Validation/FullpathValidation.php: -------------------------------------------------------------------------------- 1 | validateDuplicities($builder); 16 | } 17 | 18 | protected function validateDuplicities(SchemaBuilder $builder): void 19 | { 20 | $controllers = $builder->getControllers(); 21 | 22 | // Init paths 23 | $paths = [ 24 | Endpoint::METHOD_GET => [], 25 | Endpoint::METHOD_POST => [], 26 | Endpoint::METHOD_PUT => [], 27 | Endpoint::METHOD_DELETE => [], 28 | Endpoint::METHOD_OPTIONS => [], 29 | Endpoint::METHOD_PATCH => [], 30 | Endpoint::METHOD_HEAD => [], 31 | ]; 32 | 33 | foreach ($controllers as $controller) { 34 | foreach ($controller->getMethods() as $method) { 35 | foreach ($method->getHttpMethods() as $httpMethod) { 36 | 37 | $maskp = array_merge( 38 | $controller->getGroupPaths(), 39 | [$controller->getPath()], 40 | [$method->getPath()] 41 | ); 42 | $mask = implode('/', $maskp); 43 | $mask = Helpers::slashless($mask); 44 | $mask = '/' . trim($mask, '/'); 45 | 46 | if (array_key_exists($mask, $paths[$httpMethod])) { 47 | throw new InvalidSchemaException( 48 | sprintf( 49 | 'Duplicate path "%s" in "%s()" and "%s()"', 50 | $mask, 51 | $controller->getClass() . '::' . $method->getName(), 52 | $paths[$httpMethod][$mask]['controller']->getClass() . '::' . $paths[$httpMethod][$mask]['method']->getName() 53 | ) 54 | ); 55 | } 56 | 57 | $paths[$httpMethod][$mask] = [ 58 | 'controller' => $controller, 59 | 'method' => $method, 60 | ]; 61 | } 62 | } 63 | } 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/Core/Schema/Validation/GroupPathValidation.php: -------------------------------------------------------------------------------- 1 | validateSlashes($builder); 15 | $this->validateRegex($builder); 16 | } 17 | 18 | protected function validateSlashes(SchemaBuilder $builder): void 19 | { 20 | $controllers = $builder->getControllers(); 21 | 22 | foreach ($controllers as $controller) { 23 | foreach ($controller->getGroupPaths() as $groupPath) { 24 | if ($groupPath === '/') { 25 | // INVALID: nonsense 26 | throw new InvalidSchemaException( 27 | sprintf('@Path "%s" in "%s" cannot be only "/", it is nonsense.', $groupPath, $controller->getClass()) 28 | ); 29 | } 30 | 31 | // MUST: Starts with slash (/) 32 | if (substr($groupPath, 0, 1) !== '/') { 33 | throw new InvalidSchemaException( 34 | sprintf('@Path "%s" in "%s" must starts with "/" (slash).', $groupPath, $controller->getClass()) 35 | ); 36 | } 37 | 38 | // MUST NOT: Ends with slash (/) 39 | if (substr($groupPath, -1, 1) === '/') { 40 | throw new InvalidSchemaException( 41 | sprintf('@Path "%s" in "%s" must not ends with "/" (slash).', $groupPath, $controller->getClass()) 42 | ); 43 | } 44 | } 45 | } 46 | } 47 | 48 | protected function validateRegex(SchemaBuilder $builder): void 49 | { 50 | $controllers = $builder->getControllers(); 51 | 52 | foreach ($controllers as $controller) { 53 | $paths = $controller->getGroupPaths(); 54 | 55 | foreach ($paths as $path) { 56 | // Allowed characters: 57 | // -> a-z 58 | // -> A-Z 59 | // -> 0-9 60 | // -> -_/{} 61 | $match = Regex::match($path, '#([^a-zA-Z0-9\-_/{}]+)#'); 62 | 63 | if ($match !== null) { 64 | throw new InvalidSchemaException( 65 | sprintf( 66 | '@Path "%s" in "%s" contains illegal characters "%s". Allowed characters are only [a-zA-Z0-9-_/{}].', 67 | $path, 68 | $controller->getClass(), 69 | $match[1] 70 | ) 71 | ); 72 | } 73 | 74 | // Allowed parameter characters: 75 | // -> a-z 76 | // -> A-Z 77 | // -> 0-9 78 | // -> -_ 79 | // @regex https://regex101.com/r/APckUJ/3 80 | $matches = Regex::matchAll($path, '#\{(.+)\}#U'); 81 | if ($matches !== null) { 82 | foreach ($matches as $item) { 83 | $match = Regex::match($item[1], '#.*([^a-zA-Z0-9\-_]+).*#'); 84 | 85 | if ($match !== null) { 86 | throw (new InvalidSchemaException( 87 | sprintf( 88 | '@Path "%s" in "%s" contains illegal characters "%s" in parameter. Allowed characters in parameter are only {[a-z-A-Z0-9-_]+}', 89 | $path, 90 | $controller->getClass(), 91 | $match[1] 92 | ) 93 | )) 94 | ->withController($controller); 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /src/Core/Schema/Validation/IValidation.php: -------------------------------------------------------------------------------- 1 | validateDuplicities($builder); 15 | $this->validateRegex($builder); 16 | } 17 | 18 | protected function validateDuplicities(SchemaBuilder $builder): void 19 | { 20 | $controllers = $builder->getControllers(); 21 | $ids = []; 22 | 23 | foreach ($controllers as $controller) { 24 | foreach ($controller->getMethods() as $method) { 25 | // Skip if @Id is not set 26 | if ($method->getId() === null || $method->getId() === '') { 27 | continue; 28 | } 29 | 30 | $fullid = implode('.', array_merge( 31 | $controller->getGroupIds(), 32 | [$controller->getId()], 33 | [$method->getId()] 34 | )); 35 | 36 | // If this @GroupId(s).@ControllerId.@Id exists, throw an exception 37 | if (isset($ids[$fullid])) { 38 | throw new InvalidSchemaException( 39 | sprintf( 40 | 'Duplicate @Id "%s" in "%s::%s()" and "%s::%s()"', 41 | $fullid, 42 | $controller->getClass(), 43 | $method->getName(), 44 | $ids[$fullid]['controller']->getClass(), 45 | $ids[$fullid]['method']->getName() 46 | ) 47 | ); 48 | } 49 | 50 | $ids[$fullid] = ['controller' => $controller, 'method' => $method]; 51 | } 52 | } 53 | } 54 | 55 | protected function validateRegex(SchemaBuilder $builder): void 56 | { 57 | $controllers = $builder->getControllers(); 58 | 59 | foreach ($controllers as $controller) { 60 | foreach ($controller->getMethods() as $method) { 61 | // Skip if @Id is not set 62 | if ($method->getId() === null || $method->getId() === '') { 63 | continue; 64 | } 65 | 66 | $id = $method->getId(); 67 | 68 | // Allowed characters: 69 | // -> a-z 70 | // -> A-Z 71 | // -> 0-9 72 | // -> _ 73 | $match = Regex::match($id, '#([^a-zA-Z0-9_]+)#'); 74 | 75 | if ($match !== null) { 76 | throw new InvalidSchemaException( 77 | sprintf( 78 | '@Id "%s" in "%s::%s()" contains illegal characters "%s". Allowed characters are only [a-zA-Z0-9_].', 79 | $id, 80 | $controller->getClass(), 81 | $method->getName(), 82 | $match[1] 83 | ) 84 | ); 85 | } 86 | } 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/Core/Schema/Validation/NegotiationValidation.php: -------------------------------------------------------------------------------- 1 | getControllers() as $controller) { 15 | foreach ($controller->getMethods() as $method) { 16 | 17 | $haveDefault = null; 18 | $takenSuffixes = []; 19 | foreach ($method->getNegotiations() as $negotiation) { 20 | if ($negotiation->isDefault()) { 21 | if ($haveDefault !== null) { 22 | throw new InvalidSchemaException(sprintf( 23 | 'Multiple negotiations with "default=true" given in "%s::%s()". Only one negotiation could be default.', 24 | $controller->getClass(), 25 | $method->getName() 26 | )); 27 | } 28 | 29 | $haveDefault = $negotiation; 30 | } 31 | 32 | if (!isset($takenSuffixes[$negotiation->getSuffix()])) { 33 | $takenSuffixes[$negotiation->getSuffix()] = $negotiation; 34 | } else { 35 | throw new InvalidSchemaException(sprintf( 36 | 'Multiple negotiations with "suffix=%s" given in "%s::%s()". Each negotiation must have unique suffix', 37 | $negotiation->getSuffix(), 38 | $controller->getClass(), 39 | $method->getName() 40 | )); 41 | } 42 | 43 | $renderer = $negotiation->getRenderer(); 44 | if ($renderer !== null) { 45 | if (!class_exists($renderer)) { 46 | throw new InvalidSchemaException(sprintf( 47 | 'Negotiation renderer "%s" in "%s::%s()" does not exists', 48 | $renderer, 49 | $controller->getClass(), 50 | $method->getName() 51 | )); 52 | } 53 | 54 | $reflection = new ReflectionClass($renderer); 55 | if (!$reflection->hasMethod('__invoke')) { 56 | throw new InvalidSchemaException(sprintf( 57 | 'Negotiation renderer "%s" in "%s::%s()" does not implement __invoke(ApiRequest $request, ApiResponse $response, array $context): ApiResponse', 58 | $renderer, 59 | $controller->getClass(), 60 | $method->getName() 61 | )); 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/Core/Schema/Validation/PathValidation.php: -------------------------------------------------------------------------------- 1 | validateRequirements($builder); 15 | $this->validateSlashes($builder); 16 | $this->validateRegex($builder); 17 | } 18 | 19 | protected function validateRequirements(SchemaBuilder $builder): void 20 | { 21 | $controllers = $builder->getControllers(); 22 | foreach ($controllers as $controller) { 23 | foreach ($controller->getMethods() as $method) { 24 | if ($method->getPath() === '') { 25 | throw (new InvalidSchemaException( 26 | sprintf( 27 | '"%s::%s()" has empty @Path.', 28 | $controller->getClass(), 29 | $method->getName() 30 | ) 31 | )) 32 | ->withController($controller) 33 | ->withMethod($method); 34 | } 35 | } 36 | } 37 | } 38 | 39 | protected function validateSlashes(SchemaBuilder $builder): void 40 | { 41 | $controllers = $builder->getControllers(); 42 | 43 | foreach ($controllers as $controller) { 44 | foreach ($controller->getMethods() as $method) { 45 | $path = $method->getPath(); 46 | 47 | // MUST: Starts with slash (/) 48 | if (substr($path, 0, 1) !== '/') { 49 | throw (new InvalidSchemaException( 50 | sprintf( 51 | '@Path "%s" in "%s::%s()" must starts with "/" (slash).', 52 | $path, 53 | $controller->getClass(), 54 | $method->getName() 55 | ) 56 | )) 57 | ->withController($controller) 58 | ->withMethod($method); 59 | } 60 | 61 | // MUST NOT: Ends with slash (/), except single '/' path 62 | if (substr($path, -1, 1) === '/' && strlen($path) > 1) { 63 | throw (new InvalidSchemaException( 64 | sprintf( 65 | '@Path "%s" in "%s::%s()" must not ends with "/" (slash).', 66 | $path, 67 | $controller->getClass(), 68 | $method->getName() 69 | ) 70 | )) 71 | ->withController($controller) 72 | ->withMethod($method); 73 | } 74 | } 75 | } 76 | } 77 | 78 | protected function validateRegex(SchemaBuilder $builder): void 79 | { 80 | $controllers = $builder->getControllers(); 81 | 82 | foreach ($controllers as $controller) { 83 | foreach ($controller->getMethods() as $method) { 84 | $path = $method->getPath(); 85 | 86 | // Allowed characters: 87 | // -> a-z 88 | // -> A-Z 89 | // -> 0-9 90 | // -> -_/{} 91 | // @regex https://regex101.com/r/d7f5YI/1 92 | $match = Regex::match($path, '#([^a-zA-Z0-9\-_\/{}]+)#'); 93 | 94 | if ($match !== null) { 95 | throw (new InvalidSchemaException( 96 | sprintf( 97 | '@Path "%s" in "%s::%s()" contains illegal characters "%s". Allowed characters are only [a-zA-Z0-9-_/{}].', 98 | $path, 99 | $controller->getClass(), 100 | $method->getName(), 101 | $match[1] 102 | ) 103 | )) 104 | ->withController($controller) 105 | ->withMethod($method); 106 | } 107 | 108 | // Allowed parameter characters: 109 | // -> a-z 110 | // -> A-Z 111 | // -> 0-9 112 | // -> -_ 113 | // @regex https://regex101.com/r/APckUJ/3 114 | $matches = Regex::matchAll($path, '#\{(.+)\}#U'); 115 | if ($matches !== null) { 116 | foreach ($matches as $item) { 117 | $match = Regex::match($item[1], '#.*([^a-zA-Z0-9\-_]+).*#'); 118 | 119 | if ($match !== null) { 120 | throw (new InvalidSchemaException( 121 | sprintf( 122 | '@Path "%s" in "%s::%s()" contains illegal characters "%s" in parameter. Allowed characters in parameter are only {[a-z-A-Z0-9-_]+}', 123 | $path, 124 | $controller->getClass(), 125 | $method->getName(), 126 | $match[1] 127 | ) 128 | )) 129 | ->withController($controller) 130 | ->withMethod($method); 131 | } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/Core/Schema/Validation/RequestBodyValidation.php: -------------------------------------------------------------------------------- 1 | validateEntityClassname($builder); 14 | } 15 | 16 | protected function validateEntityClassname(SchemaBuilder $builder): void 17 | { 18 | $controllers = $builder->getControllers(); 19 | 20 | foreach ($controllers as $controller) { 21 | foreach ($controller->getMethods() as $method) { 22 | 23 | $requestBody = $method->getRequestBody(); 24 | 25 | if ($requestBody === null) { 26 | continue; 27 | } 28 | 29 | $entity = $requestBody->getEntity(); 30 | 31 | if ($entity === null) { 32 | continue; 33 | } 34 | 35 | if (!class_exists($entity, true)) { 36 | throw new InvalidSchemaException( 37 | sprintf( 38 | 'Request entity "%s" in "%s::%s()" does not exist"', 39 | $requestBody->getEntity(), 40 | $controller->getClass(), 41 | $method->getName() 42 | ) 43 | ); 44 | } 45 | } 46 | } 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/Core/Schema/Validation/RequestParameterValidation.php: -------------------------------------------------------------------------------- 1 | */ 15 | private array $allowedTypes; 16 | 17 | /** 18 | * @param array $allowedTypes 19 | */ 20 | public function __construct(array $allowedTypes = EndpointParameter::TYPES) 21 | { 22 | $this->allowedTypes = $allowedTypes; 23 | } 24 | 25 | public function validate(SchemaBuilder $builder): void 26 | { 27 | $this->validateInParameters($builder); 28 | $this->validateTypeParameters($builder); 29 | $this->validateMaskParametersAreInPath($builder); 30 | } 31 | 32 | protected function validateInParameters(SchemaBuilder $builder): void 33 | { 34 | foreach ($builder->getControllers() as $controller) { 35 | foreach ($controller->getMethods() as $method) { 36 | foreach ($method->getParameters() as $parameter) { 37 | if (!in_array($parameter->getIn(), EndpointParameter::IN, true)) { 38 | throw new InvalidSchemaException(sprintf( 39 | 'Invalid request parameter "in=%s" given in "%s::%s()". Choose one of %s', 40 | $parameter->getIn(), 41 | $controller->getClass(), 42 | $method->getName(), 43 | implode(', ', EndpointParameter::IN) 44 | )); 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | protected function validateTypeParameters(SchemaBuilder $builder): void 52 | { 53 | foreach ($builder->getControllers() as $controller) { 54 | foreach ($controller->getMethods() as $method) { 55 | foreach ($method->getParameters() as $parameter) { 56 | // Types 57 | if (!in_array($parameter->getType(), $this->allowedTypes, true)) { 58 | throw new InvalidSchemaException(sprintf( 59 | 'Invalid request parameter "type=%s" given in "%s::%s()". Choose one of %s', 60 | $parameter->getType(), 61 | $controller->getClass(), 62 | $method->getName(), 63 | implode(', ', $this->allowedTypes) 64 | )); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | protected function validateMaskParametersAreInPath(SchemaBuilder $builder): void 72 | { 73 | foreach ($builder->getControllers() as $controller) { 74 | foreach ($controller->getMethods() as $method) { 75 | // Check if parameters in mask are in path 76 | /** @var EndpointParameter[] $pathParameters */ 77 | $pathParameters = array_filter($method->getParameters(), static fn (EndpointParameter $parameter): bool => $parameter->getIn() === EndpointParameter::IN_PATH); 78 | 79 | $maskParameters = []; 80 | $maskp = array_merge( 81 | $controller->getGroupPaths(), 82 | [$controller->getPath()], 83 | [$method->getPath()] 84 | ); 85 | 86 | $mask = implode('/', $maskp); 87 | $mask = Helpers::slashless($mask); 88 | $mask = '/' . trim($mask, '/'); 89 | 90 | // Collect variable parameters from URL 91 | // @phpcs:ignore SlevomatCodingStandard.PHP.DisallowReference.DisallowedInheritingVariableByReference 92 | Regex::replaceCallback($mask, '#{([a-zA-Z0-9\-_]+)}#U', static function ($matches) use (&$maskParameters): string { 93 | [, $variableName] = $matches; 94 | 95 | // Build parameter pattern 96 | $pattern = sprintf('(?P<%s>[^/]+)', $variableName); 97 | 98 | // Build mask parameters 99 | $maskParameters[$variableName] = [ 100 | 'name' => $variableName, 101 | 'pattern' => $pattern, 102 | ]; 103 | 104 | // Returned pattern replace {variable} in mask 105 | return $pattern; 106 | }); 107 | 108 | foreach ($maskParameters as $maskParameter) { 109 | foreach ($pathParameters as $parameter) { 110 | if ($maskParameter['name'] === $parameter->getName()) { 111 | continue 2; 112 | } 113 | } 114 | 115 | throw new InvalidSchemaException(sprintf( 116 | 'Mask parameter "%s" is not defined as @RequestParameter(in=path) in "%s"', 117 | $maskParameter['name'], 118 | $controller->getClass() 119 | )); 120 | } 121 | } 122 | } 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /src/Core/Schema/Validator/SchemaBuilderValidator.php: -------------------------------------------------------------------------------- 1 | validators[] = $validator; 17 | } 18 | 19 | public function validate(SchemaBuilder $builder): void 20 | { 21 | foreach ($this->validators as $validator) { 22 | $validator->validate($builder); 23 | } 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/Core/UI/Controller/IController.php: -------------------------------------------------------------------------------- 1 | getContainerBuilder(); 44 | $global = $this->compiler->getExtension()->getConfig(); 45 | $config = $this->config; 46 | 47 | if (!$global->debug) { 48 | return; 49 | } 50 | 51 | if ($config->debug->panel) { 52 | $builder->addDefinition($this->prefix('panel')) 53 | ->setFactory(ApiPanel::class); 54 | } 55 | 56 | if ($config->debug->negotiation) { 57 | $this->loadNegotiationDebugConfiguration(); 58 | } 59 | 60 | // BlueScreen - runtime 61 | ApiBlueScreen::register(Debugger::getBlueScreen()); 62 | ValidationBlueScreen::register(Debugger::getBlueScreen()); 63 | } 64 | 65 | public function afterPluginCompile(ClassType $class): void 66 | { 67 | $global = $this->compiler->getExtension()->getConfig(); 68 | $config = $this->config; 69 | 70 | $initialize = $class->getMethod('initialize'); 71 | 72 | $initialize->addBody('?::register($this->getService(?));', [ContainerBuilder::literal(ApiBlueScreen::class), 'tracy.blueScreen']); 73 | $initialize->addBody('?::register($this->getService(?));', [ContainerBuilder::literal(ValidationBlueScreen::class), 'tracy.blueScreen']); 74 | 75 | if ($global->debug && $config->debug->panel) { 76 | $initialize->addBody('$this->getService(?)->addPanel($this->getService(?));', ['tracy.bar', $this->prefix('panel')]); 77 | } 78 | } 79 | 80 | protected function getConfigSchema(): Schema 81 | { 82 | return Expect::structure([ 83 | 'debug' => Expect::structure([ 84 | 'panel' => Expect::bool(false), 85 | 'negotiation' => Expect::bool(true), 86 | ]), 87 | ]); 88 | } 89 | 90 | protected function loadNegotiationDebugConfiguration(): void 91 | { 92 | // Skip if plugin apitte/negotiation is not loaded 93 | if ($this->compiler->getPluginByType(NegotiationPlugin::class) === null) { 94 | return; 95 | } 96 | 97 | $builder = $this->getContainerBuilder(); 98 | 99 | $builder->addDefinition($this->prefix('transformer.debug')) 100 | ->setFactory(DebugTransformer::class) 101 | ->addTag(ApiExtension::NEGOTIATION_TRANSFORMER_TAG, ['suffix' => 'debug']); 102 | 103 | $builder->addDefinition($this->prefix('transformer.debugdata')) 104 | ->setFactory(DebugDataTransformer::class) 105 | ->addTag(ApiExtension::NEGOTIATION_TRANSFORMER_TAG, ['suffix' => 'debugdata']); 106 | 107 | // Setup debug schema decorator 108 | CoreSchemaPlugin::$decorators['debug'] = new DebugSchemaDecorator(); 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/Debug/Negotiation/Transformer/DebugDataTransformer.php: -------------------------------------------------------------------------------- 1 | maxDepth = $maxDepth; 21 | $this->maxLength = $maxLength; 22 | } 23 | 24 | /** 25 | * @param mixed[] $context 26 | */ 27 | public function transform(ApiRequest $request, ApiResponse $response, array $context = []): ApiResponse 28 | { 29 | Debugger::$maxDepth = $this->maxDepth; 30 | Debugger::$maxLength = $this->maxLength; 31 | 32 | $tmp = clone $response; 33 | 34 | if (isset($context['exception'])) { 35 | // Handle and display exception 36 | Debugger::exceptionHandler($context['exception']); 37 | exit; 38 | } 39 | 40 | $response = $response->withHeader('Content-Type', 'text/html') 41 | ->withBody(Utils::streamFor()) 42 | ->withStatus(599); 43 | 44 | $response->getBody()->write(Debugger::dump($tmp->getEntity(), true)); 45 | 46 | return $response; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/Debug/Negotiation/Transformer/DebugTransformer.php: -------------------------------------------------------------------------------- 1 | maxDepth = $maxDepth; 21 | $this->maxLength = $maxLength; 22 | } 23 | 24 | /** 25 | * @param mixed[] $context 26 | */ 27 | public function transform(ApiRequest $request, ApiResponse $response, array $context = []): ApiResponse 28 | { 29 | Debugger::$maxDepth = $this->maxDepth; 30 | Debugger::$maxLength = $this->maxLength; 31 | 32 | $tmp = clone $response; 33 | 34 | $response = $response->withHeader('Content-Type', 'text/html') 35 | ->withBody(Utils::streamFor()) 36 | ->withStatus(599); 37 | 38 | $response->getBody()->write(Debugger::dump($tmp, true)); 39 | 40 | if (isset($context['exception'])) { 41 | $response->getBody()->write(Debugger::dump($context['exception'], true)); 42 | } 43 | 44 | return $response; 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/Debug/Schema/Serialization/DebugSchemaDecorator.php: -------------------------------------------------------------------------------- 1 | getControllers() as $controller) { 14 | foreach ($controller->getMethods() as $method) { 15 | $method->addNegotiation('.debugdata'); 16 | $method->addNegotiation('.debug'); 17 | } 18 | } 19 | 20 | return $builder; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Debug/Tracy/BlueScreen/ApiBlueScreen.php: -------------------------------------------------------------------------------- 1 | addPanel(static function ($e): ?array { 15 | if (!($e instanceof ApiException)) { 16 | return null; 17 | } 18 | 19 | if (!$e->getContext()) { 20 | return null; 21 | } 22 | 23 | return [ 24 | 'tab' => self::renderTab($e), 25 | 'panel' => self::renderPanel($e), 26 | ]; 27 | }); 28 | } 29 | 30 | private static function renderTab(ApiException $e): string 31 | { 32 | return 'Apitte'; 33 | } 34 | 35 | private static function renderPanel(ApiException $e): string 36 | { 37 | return Dumper::toHtml($e->getContext()); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/Debug/Tracy/BlueScreen/ValidationBlueScreen.php: -------------------------------------------------------------------------------- 1 | addPanel(static function ($e): ?array { 16 | if (!($e instanceof InvalidSchemaException)) { 17 | return null; 18 | } 19 | 20 | return [ 21 | 'tab' => self::renderTab($e), 22 | 'panel' => self::renderPanel($e), 23 | ]; 24 | }); 25 | } 26 | 27 | private static function renderTab(InvalidSchemaException $e): string 28 | { 29 | return 'Apitte - Validation'; 30 | } 31 | 32 | private static function renderPanel(InvalidSchemaException $e): ?string 33 | { 34 | if ($e->controller === null || $e->method === null || !class_exists($e->controller->getClass())) { 35 | return null; 36 | } 37 | 38 | $rf = new ReflectionClass($e->controller->getClass()); 39 | $rm = $rf->getMethod($e->method->getName()); 40 | 41 | return '

File:' . Helpers::editorLink($rf->getFileName(), $rm->getStartLine()) . '

' 42 | . BlueScreen::highlightFile((string) $rf->getFileName(), (int) $rm->getStartLine(), 20); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/Debug/Tracy/Panel/ApiPanel.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 16 | } 17 | 18 | /** 19 | * Renders HTML code for custom tab. 20 | */ 21 | public function getTab(): string 22 | { 23 | // phpcs:disable 24 | ob_start(); 25 | $schema = $this->schema; 26 | require __DIR__ . '/templates/tab.phtml'; 27 | 28 | return (string) ob_get_clean(); 29 | // phpcs:enable 30 | } 31 | 32 | /** 33 | * Renders HTML code for custom panel. 34 | */ 35 | public function getPanel(): string 36 | { 37 | // phpcs:disable 38 | ob_start(); 39 | $schema = $this->schema; 40 | require __DIR__ . '/templates/panel.phtml'; 41 | 42 | return (string) ob_get_clean(); 43 | // phpcs:enable 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/Debug/Tracy/Panel/templates/panel.phtml: -------------------------------------------------------------------------------- 1 | 7 | 8 |

API

9 | 10 |

Endpoints (getEndpoints()) ?>)

11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | getEndpoints() as $endpoint) { ?> 20 | getHandler()->getClass()); 22 | $methodRf = $endpointRf->getMethod($endpoint->getHandler()->getMethod()); 23 | ?> 24 | 25 | 26 | 27 | 32 | 33 | 34 |
MethodMaskHandler
getMethods()) ?>getMask() ?> 28 | 29 | getHandler()->getClass() ?>::getHandler()->getMethod() ?>() 30 | 31 |
35 |
36 | 37 |

Schema

38 | 39 |
40 | true]); ?> 41 |
42 | -------------------------------------------------------------------------------- /src/Debug/Tracy/Panel/templates/tab.phtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | getEndpoints())?> routes 4 | 5 | -------------------------------------------------------------------------------- /src/Middlewares/ApiMiddleware.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 25 | $this->errorHandler = $errorHandler; 26 | } 27 | 28 | public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next): ResponseInterface 29 | { 30 | if (!$request instanceof ApiRequest) { 31 | $request = new ApiRequest($request); 32 | } 33 | 34 | if (!$response instanceof ApiResponse) { 35 | $response = new ApiResponse($response); 36 | } 37 | 38 | // Pass this API request/response objects to API dispatcher 39 | try { 40 | $response = $this->dispatcher->dispatch($request, $response); 41 | } catch (Throwable $exception) { 42 | $response = $this->errorHandler->handle(new DispatchError($exception, $request)); 43 | } 44 | 45 | // Pass response to next middleware 46 | $response = $next($request, $response); 47 | 48 | return $response; 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/Middlewares/DI/MiddlewaresPlugin.php: -------------------------------------------------------------------------------- 1 | getContainerBuilder(); 31 | $globalConfig = $this->compiler->getExtension()->getConfig(); 32 | $config = $this->config; 33 | 34 | if ($config->tracy) { 35 | $builder->addDefinition($this->prefix('tracy')) 36 | ->setFactory(TracyMiddleware::class . '::factory', [$globalConfig->debug]) 37 | ->addTag(MiddlewaresExtension::MIDDLEWARE_TAG, ['priority' => 100]); 38 | } 39 | 40 | if ($config->autobasepath) { 41 | $builder->addDefinition($this->prefix('autobasepath')) 42 | ->setFactory(AutoBasePathMiddleware::class) 43 | ->addTag(MiddlewaresExtension::MIDDLEWARE_TAG, ['priority' => 200]); 44 | } 45 | 46 | $builder->addDefinition($this->prefix('api')) 47 | ->setFactory(ApiMiddleware::class) 48 | ->addTag(MiddlewaresExtension::MIDDLEWARE_TAG, ['priority' => 500]); 49 | } 50 | 51 | protected function getConfigSchema(): Schema 52 | { 53 | return Expect::structure([ 54 | 'tracy' => Expect::bool(true), 55 | 'autobasepath' => Expect::bool(true), 56 | ]); 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/Negotiation/ContentNegotiation.php: -------------------------------------------------------------------------------- 1 | addNegotiations($negotiators); 24 | } 25 | 26 | /** 27 | * @param INegotiator[] $negotiators 28 | */ 29 | public function addNegotiations(array $negotiators): void 30 | { 31 | foreach ($negotiators as $negotiator) { 32 | $this->addNegotiation($negotiator); 33 | } 34 | } 35 | 36 | public function addNegotiation(INegotiator $negotiator): void 37 | { 38 | $this->negotiators[] = $negotiator; 39 | } 40 | 41 | /** 42 | * @param mixed[] $context 43 | */ 44 | public function negotiate(ApiRequest $request, ApiResponse $response, array $context = []): ApiResponse 45 | { 46 | // Should we skip negotiation? 47 | if ($request->getAttribute(self::ATTR_SKIP, false) === true) 48 | 49 | return $response; 50 | 51 | // Validation 52 | if ($this->negotiators === []) { 53 | throw new InvalidStateException('At least one response negotiator is required'); 54 | } 55 | 56 | foreach ($this->negotiators as $negotiator) { 57 | // Pass to negotiator and check return value 58 | $negotiated = $negotiator->negotiate($request, $response, $context); 59 | 60 | // If it's not NULL, we have an ApiResponse 61 | if ($negotiated !== null) 62 | 63 | return $negotiated; 64 | } 65 | 66 | return $response; 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/Negotiation/Decorator/ResponseEntityDecorator.php: -------------------------------------------------------------------------------- 1 | negotiation = $negotiation; 20 | } 21 | 22 | public function decorateError(ApiRequest $request, ApiResponse $response, ApiException $error): ApiResponse 23 | { 24 | return $this->negotiation->negotiate($request, $response, ['exception' => $error]); 25 | } 26 | 27 | public function decorateResponse(ApiRequest $request, ApiResponse $response): ApiResponse 28 | { 29 | // Cannot negotiate response without entity 30 | if ($response->getEntity() === null) { 31 | return $response; 32 | } 33 | 34 | return $this->negotiation->negotiate($request, $response); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/Negotiation/DefaultNegotiator.php: -------------------------------------------------------------------------------- 1 | addTransformers($transformers); 22 | } 23 | 24 | /** 25 | * @param mixed[] $context 26 | */ 27 | public function negotiate(ApiRequest $request, ApiResponse $response, array $context = []): ?ApiResponse 28 | { 29 | if ($this->transformers === []) { 30 | throw new InvalidStateException('Please add at least one transformer'); 31 | } 32 | 33 | // Early return if there's no endpoint 34 | $endpoint = $response->getEndpoint(); 35 | if ($endpoint === null) 36 | 37 | return null; 38 | 39 | // Get negotiations 40 | $negotiations = $endpoint->getNegotiations(); 41 | 42 | // Try default 43 | foreach ($negotiations as $negotiation) { 44 | // Skip non default negotiations 45 | if (!$negotiation->isDefault()) 46 | 47 | continue; 48 | 49 | // Normalize suffix for transformer 50 | $transformer = ltrim($negotiation->getSuffix(), '.'); 51 | 52 | // If callback is defined -> process to callback transformer 53 | if ($negotiation->getRenderer() !== null) { 54 | $transformer = INegotiator::RENDERER; 55 | $context['renderer'] = $negotiation->getRenderer(); 56 | } 57 | 58 | // Try default negotiation 59 | if (!isset($this->transformers[$transformer])) { 60 | throw new InvalidStateException(sprintf('Transformer "%s" not registered', $transformer)); 61 | } 62 | 63 | // Transform (fallback) data to given format 64 | return $this->transformers[$transformer]->transform($request, $response, $context); 65 | } 66 | 67 | return null; 68 | } 69 | 70 | /** 71 | * @param ITransformer[] $transformers 72 | */ 73 | private function addTransformers(array $transformers): void 74 | { 75 | foreach ($transformers as $suffix => $transformer) { 76 | $this->addTransformer($suffix, $transformer); 77 | } 78 | } 79 | 80 | private function addTransformer(string $suffix, ITransformer $transformer): void 81 | { 82 | $this->transformers[$suffix] = $transformer; 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/Negotiation/FallbackNegotiator.php: -------------------------------------------------------------------------------- 1 | transformer = $transformer; 17 | } 18 | 19 | /** 20 | * @param mixed[] $context 21 | */ 22 | public function negotiate(ApiRequest $request, ApiResponse $response, array $context = []): ?ApiResponse 23 | { 24 | return $this->transformer->transform($request, $response, $context); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/Negotiation/Http/AbstractEntity.php: -------------------------------------------------------------------------------- 1 | data = $data; 13 | } 14 | 15 | public function getData(): mixed 16 | { 17 | return $this->data; 18 | } 19 | 20 | protected function setData(mixed $data): void 21 | { 22 | $this->data = $data; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/Negotiation/Http/ArrayEntity.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class ArrayEntity extends AbstractEntity implements IteratorAggregate, Countable 14 | { 15 | 16 | /** 17 | * @param mixed[] $data 18 | */ 19 | public function __construct(array $data) 20 | { 21 | parent::__construct($data); 22 | } 23 | 24 | /** 25 | * @param mixed[] $data 26 | * @return static 27 | */ 28 | public static function from(array $data): self 29 | { 30 | return new static($data); 31 | } 32 | 33 | /** 34 | * @return mixed[] 35 | */ 36 | public function toArray(): array 37 | { 38 | return (array) $this->getData(); 39 | } 40 | 41 | /** 42 | * @return ArrayIterator 43 | */ 44 | public function getIterator(): ArrayIterator 45 | { 46 | return new ArrayIterator($this->toArray()); 47 | } 48 | 49 | public function count(): int 50 | { 51 | return count($this->toArray()); 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/Negotiation/Http/CsvEntity.php: -------------------------------------------------------------------------------- 1 | header = $header; 21 | $this->update(); 22 | 23 | return $this; 24 | } 25 | 26 | /** 27 | * @param string[] $rows 28 | * @return static 29 | */ 30 | public function withRows(array $rows): static 31 | { 32 | $this->rows = $rows; 33 | $this->update(); 34 | 35 | return $this; 36 | } 37 | 38 | private function update(): void 39 | { 40 | $this->setData($this->header === [] ? $this->rows : array_merge([$this->header], $this->rows)); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/Negotiation/Http/MappingEntity.php: -------------------------------------------------------------------------------- 1 | entity = $entity; 17 | } 18 | 19 | /** 20 | * @return static 21 | */ 22 | public static function from(IResponseEntity $entity): self 23 | { 24 | return new static($entity); 25 | } 26 | 27 | /** 28 | * @return mixed[] 29 | */ 30 | public function getData(): array 31 | { 32 | return $this->entity->toResponse(); 33 | } 34 | 35 | public function getEntity(): IResponseEntity 36 | { 37 | return $this->entity; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/Negotiation/Http/ObjectEntity.php: -------------------------------------------------------------------------------- 1 | addTransformers($transformers); 22 | } 23 | 24 | /** 25 | * @param mixed[] $context 26 | */ 27 | public function negotiate(ApiRequest $request, ApiResponse $response, array $context = []): ?ApiResponse 28 | { 29 | if ($this->transformers === []) { 30 | throw new InvalidStateException('Please add at least one transformer'); 31 | } 32 | 33 | // Early return if there's no endpoint 34 | $endpoint = $response->getEndpoint(); 35 | if ($endpoint === null) 36 | 37 | return null; 38 | 39 | // Get negotiations 40 | $negotiations = $endpoint->getNegotiations(); 41 | 42 | // Try match by allowed negotiations 43 | foreach ($negotiations as $negotiation) { 44 | // Normalize suffix 45 | $suffix = sprintf('.%s', ltrim($negotiation->getSuffix(), '.')); 46 | 47 | // Try match by suffix 48 | if ($this->match($request->getUri()->getPath(), $suffix)) { 49 | $transformer = ltrim($suffix, '.'); 50 | 51 | // If callback is defined -> process to callback transformer 52 | if ($negotiation->getRenderer() !== null) { 53 | $transformer = INegotiator::RENDERER; 54 | $context['renderer'] = $negotiation->getRenderer(); 55 | } 56 | 57 | if (!isset($this->transformers[$transformer])) { 58 | throw new InvalidStateException(sprintf('Transformer "%s" not registered', $transformer)); 59 | } 60 | 61 | return $this->transformers[$transformer]->transform($request, $response, $context); 62 | } 63 | } 64 | 65 | return null; 66 | } 67 | 68 | /** 69 | * @param ITransformer[] $transformers 70 | */ 71 | private function addTransformers(array $transformers): void 72 | { 73 | foreach ($transformers as $suffix => $transformer) { 74 | $this->addTransformer($suffix, $transformer); 75 | } 76 | } 77 | 78 | private function addTransformer(string $suffix, ITransformer $transformer): void 79 | { 80 | $this->transformers[$suffix] = $transformer; 81 | } 82 | 83 | /** 84 | * Match transformer for the suffix? (.json?) 85 | */ 86 | private function match(string $path, string $suffix): bool 87 | { 88 | return substr($path, -strlen($suffix)) === $suffix; 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Negotiation/Transformer/AbstractTransformer.php: -------------------------------------------------------------------------------- 1 | getEntity(); 15 | if ($entity === null) { 16 | throw new InvalidStateException('Entity is required'); 17 | } 18 | 19 | return $entity; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/Negotiation/Transformer/CsvTransformer.php: -------------------------------------------------------------------------------- 1 | transformException($context['exception'], $request, $response); 22 | } 23 | 24 | return $this->transformResponse($request, $response); 25 | } 26 | 27 | protected function transformException(ApiException $exception, ApiRequest $request, ApiResponse $response): ApiResponse 28 | { 29 | $content = sprintf('Exception occurred with message "%s"', $exception->getMessage()); 30 | $response->getBody()->write($content); 31 | 32 | // Setup content type 33 | return $response 34 | ->withStatus($exception->getCode()) 35 | ->withHeader('Content-Type', 'text/plain'); 36 | } 37 | 38 | protected function transformResponse(ApiRequest $request, ApiResponse $response): ApiResponse 39 | { 40 | $content = $this->convert($this->getEntity($response)->getData()); 41 | $response->getBody()->write($content); 42 | 43 | // Setup content type 44 | return $response 45 | ->withHeader('Content-Type', 'text/plain'); 46 | } 47 | 48 | /** 49 | * @param mixed[][] $rows 50 | */ 51 | private function convert(array $rows, string $delimiter = ',', string $enclosure = '"'): string 52 | { 53 | $fp = fopen('php://temp', 'r+'); 54 | 55 | if ($fp === false) { 56 | throw new RuntimeException('IO exception'); 57 | } 58 | 59 | foreach ($rows as $row) { 60 | foreach ($row as $item) { 61 | if (is_array($item) || !is_scalar($item)) { 62 | return 'CSV need flat array'; 63 | } 64 | } 65 | 66 | fputcsv($fp, $row, $delimiter, $enclosure); 67 | } 68 | 69 | rewind($fp); 70 | $data = fread($fp, 1048576); 71 | fclose($fp); 72 | 73 | return rtrim((string) $data, "\n"); 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/Negotiation/Transformer/ITransformer.php: -------------------------------------------------------------------------------- 1 | extractException($exception)); 24 | $response = $response->withStatus($exception->getCode()); 25 | } else { 26 | // Convert data to array to json 27 | $content = Json::encode($this->extractData($request, $response, $context)); 28 | } 29 | 30 | $response->getBody()->write($content); 31 | 32 | // Setup content type 33 | return $response 34 | ->withHeader('Content-Type', 'application/json'); 35 | } 36 | 37 | /** 38 | * @param mixed[] $context 39 | */ 40 | protected function extractData(ApiRequest $request, ApiResponse $response, array $context): mixed 41 | { 42 | return $this->getEntity($response)->getData(); 43 | } 44 | 45 | /** 46 | * @return mixed[] 47 | */ 48 | protected function extractException(ApiException $exception): array 49 | { 50 | $data = [ 51 | 'exception' => $exception->getMessage(), 52 | ]; 53 | 54 | $context = $exception->getContext(); 55 | if ($context !== null) { 56 | $data['context'] = $context; 57 | } 58 | 59 | return $data; 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/Negotiation/Transformer/JsonUnifyTransformer.php: -------------------------------------------------------------------------------- 1 | transformException($context['exception'], $request, $response) : $this->transformResponse($request, $response); 28 | 29 | // Convert data to array to json 30 | $content = Json::encode($this->getEntity($response)->getData()); 31 | $response->getBody()->write($content); 32 | 33 | // Setup content type 34 | return $response 35 | ->withHeader('Content-Type', 'application/json'); 36 | } 37 | 38 | protected function transformException(ApiException $exception, ApiRequest $request, ApiResponse $response): ApiResponse 39 | { 40 | $entityData = [ 41 | 'status' => self::STATUS_ERROR, 42 | ]; 43 | 44 | if ($exception instanceof ClientErrorException) { 45 | $entityData['data'] = [ 46 | 'code' => $exception->getCode(), 47 | 'error' => $exception->getMessage(), 48 | ]; 49 | 50 | $context = $exception->getContext(); 51 | if ($context !== null) { 52 | $entityData['data']['context'] = $context; 53 | } 54 | } else { 55 | $entityData['message'] = $exception->getMessage(); 56 | } 57 | 58 | return $response 59 | ->withStatus($exception->getCode()) 60 | ->withAttribute(ResponseAttributes::ATTR_ENTITY, ArrayEntity::from($entityData)); 61 | } 62 | 63 | protected function transformResponse(ApiRequest $request, ApiResponse $response): ApiResponse 64 | { 65 | return $response 66 | ->withAttribute(ResponseAttributes::ATTR_ENTITY, ArrayEntity::from([ 67 | 'status' => self::STATUS_SUCCESS, 68 | 'data' => $this->getEntity($response)->getData(), 69 | ])); 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/Negotiation/Transformer/RendererTransformer.php: -------------------------------------------------------------------------------- 1 | container = $container; 18 | } 19 | 20 | /** 21 | * Encode given data for response 22 | * 23 | * @param mixed[] $context 24 | */ 25 | public function transform(ApiRequest $request, ApiResponse $response, array $context = []): ApiResponse 26 | { 27 | // Return immediately if context hasn't defined renderer 28 | if (!isset($context['renderer'])) 29 | 30 | return $response; 31 | 32 | // Fetch service 33 | // @phpstan-ignore-next-line 34 | $service = $this->container->getByType($context['renderer'], false); 35 | 36 | if (!$service) { 37 | throw new InvalidStateException(sprintf('Renderer "%s" is not registered in container', $context['renderer'])); 38 | } 39 | 40 | if (!is_callable($service)) { 41 | throw new InvalidStateException(sprintf('Renderer "%s" must implement __invoke() method', $context['renderer'])); 42 | } 43 | 44 | return $service($request, $response, $context); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/OpenApi/ISchemaBuilder.php: -------------------------------------------------------------------------------- 1 | definitions[] = $definition; 18 | } 19 | 20 | public function build(): OpenApi 21 | { 22 | $data = $this->loadDefinitions(); 23 | 24 | return OpenApi::fromArray($data); 25 | } 26 | 27 | /** 28 | * @return mixed[] 29 | */ 30 | protected function loadDefinitions(): array 31 | { 32 | $data = []; 33 | foreach ($this->definitions as $definition) { 34 | $data = Helpers::merge($definition->load(), $data); 35 | } 36 | 37 | return $data; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/OpenApi/SchemaDefinition/ArrayDefinition.php: -------------------------------------------------------------------------------- 1 | data = $data; 17 | } 18 | 19 | /** 20 | * @return mixed[] 21 | */ 22 | public function load(): array 23 | { 24 | return $this->data; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/OpenApi/SchemaDefinition/BaseDefinition.php: -------------------------------------------------------------------------------- 1 | '3.0.2', 15 | 'info' => [ 16 | 'title' => 'OpenAPI', 17 | 'version' => '1.0.0', 18 | ], 19 | 'paths' => [], 20 | ]; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/OpenApi/SchemaDefinition/Entity/IEntityAdapter.php: -------------------------------------------------------------------------------- 1 | file = $file; 16 | } 17 | 18 | /** 19 | * @return mixed[] 20 | */ 21 | public function load(): array 22 | { 23 | $content = file_get_contents($this->file); 24 | if ($content === false) { 25 | throw new InvalidStateException('Cant read file ' . $this->file); 26 | } 27 | 28 | $decode = Json::decode($content, forceArrays: true); 29 | if ($decode === false || $decode === null) { 30 | return []; 31 | } 32 | 33 | return $decode; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/OpenApi/SchemaDefinition/NeonDefinition.php: -------------------------------------------------------------------------------- 1 | file = $file; 16 | } 17 | 18 | /** 19 | * @return mixed[] 20 | */ 21 | public function load(): array 22 | { 23 | $input = file_get_contents($this->file); 24 | if ($input === false) { 25 | throw new InvalidStateException('Cant read file ' . $this->file); 26 | } 27 | 28 | $decode = Neon::decode($input); 29 | if ($decode === false || $decode === null) { 30 | return []; 31 | } 32 | 33 | return $decode; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/OpenApi/SchemaDefinition/YamlDefinition.php: -------------------------------------------------------------------------------- 1 | file = $file; 15 | } 16 | 17 | /** 18 | * @return mixed[] 19 | */ 20 | public function load(): array 21 | { 22 | $decode = Yaml::parseFile($this->file); 23 | if ($decode === false || $decode === null) { 24 | return []; 25 | } 26 | 27 | return $decode; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/OpenApi/SchemaType/BaseSchemaType.php: -------------------------------------------------------------------------------- 1 | getType()) { 14 | case EndpointParameter::TYPE_STRING: 15 | return new Schema( 16 | [ 17 | 'type' => 'string', 18 | ] 19 | ); 20 | case EndpointParameter::TYPE_INTEGER: 21 | return new Schema( 22 | [ 23 | 'type' => 'integer', 24 | 'format' => 'int32', 25 | ] 26 | ); 27 | case EndpointParameter::TYPE_FLOAT: 28 | return new Schema( 29 | [ 30 | 'type' => 'float', 31 | 'format' => 'float64', 32 | ] 33 | ); 34 | case EndpointParameter::TYPE_BOOLEAN: 35 | return new Schema( 36 | [ 37 | 'type' => 'boolean', 38 | ] 39 | ); 40 | case EndpointParameter::TYPE_DATETIME: 41 | return new Schema( 42 | [ 43 | 'type' => 'string', 44 | 'format' => 'date-time', 45 | ] 46 | ); 47 | case EndpointParameter::TYPE_ENUM: 48 | return new Schema( 49 | [ 50 | 'type' => 'string', 51 | ] 52 | ); 53 | default: 54 | throw new UnknownSchemaType('Unknown endpoint parameter type ' . $endpointParameter->getType()); 55 | } 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /src/OpenApi/SchemaType/ISchemaType.php: -------------------------------------------------------------------------------- 1 | application = $application; 25 | $this->request = $request; 26 | } 27 | 28 | public function run(Request $request): Response 29 | { 30 | $url = $this->request->getUrl(); 31 | 32 | $psrRequest = Psr7ServerRequestFactory::fromNette($this->request) 33 | ->withUri(Psr7UriFactory::fromNette($url)); 34 | $psrRequest = new ApiRequest($psrRequest); 35 | 36 | return new CallbackResponse(function () use ($psrRequest): void { 37 | $this->application->runWith($psrRequest); 38 | }); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/Presenter/ApiRoute.php: -------------------------------------------------------------------------------- 1 | ', $metadata); 22 | } 23 | 24 | } 25 | --------------------------------------------------------------------------------