diff --git a/Annotation/AbstractAnnotation.php b/Annotation/AbstractAnnotation.php new file mode 100644 index 0000000..556888f --- /dev/null +++ b/Annotation/AbstractAnnotation.php @@ -0,0 +1,25 @@ + $attribute) { + $setter = 'set'.ucfirst($name); + + if (method_exists($this, $setter)) { + $this->$setter($attribute); + } + } + } +} diff --git a/Annotation/SandboxRequest.php b/Annotation/SandboxRequest.php new file mode 100644 index 0000000..55ce575 --- /dev/null +++ b/Annotation/SandboxRequest.php @@ -0,0 +1,81 @@ +"), + * @Annotation\Attribute("responses", type="array") + * }) + */ +class SandboxRequest extends AbstractAnnotation +{ + /** + * @var array + */ + private $parameters = []; + + /** + * @var array + */ + private $responses = []; + + /** + * @param Parameter $parameter + * + * @return $this + */ + public function addParameter(Parameter $parameter) + { + if (!in_array($parameter, $this->parameters)) { + $this->parameters[] = $parameter; + } + + return $this; + } + + /** + * @return array|Parameter[] + */ + public function getParameters() + { + return $this->parameters; + } + + /** + * @param array $parameters + * + * @return $this + */ + public function setParameters(array $parameters) + { + $this->parameters = $parameters; + + return $this; + } + + /** + * @return array|SandboxResponse[] + */ + public function getResponses() + { + return $this->responses; + } + + /** + * @param array $responses + * + * @return $this + */ + public function setResponses(array $responses) + { + $this->responses = $responses; + + return $this; + } +} diff --git a/Annotation/SandboxRequest/Parameter.php b/Annotation/SandboxRequest/Parameter.php new file mode 100644 index 0000000..17522ea --- /dev/null +++ b/Annotation/SandboxRequest/Parameter.php @@ -0,0 +1,174 @@ +"), + * @Annotation\Attribute("value", type="mixed"), + * @Annotation\Attribute("format", type="string"), + * }) + */ +class Parameter extends AbstractAnnotation +{ + const TYPE_STRING = 'string'; + const TYPE_INTEGER = 'integer'; + const TYPE_ARRAY = 'array'; + + /** + * @var string + */ + private $name; + + /** + * @var string + */ + private $type; + + /** + * @var boolean + */ + private $required; + + /** + * @var string + */ + private $format; + + /** + * @var string + */ + private $value; + + /** + * @var array + */ + private $children; + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * @param string $type + * + * @return $this + */ + public function setType($type) + { + $this->type = $type; + + return $this; + } + + /** + * @return bool + */ + public function isRequired() + { + return $this->required; + } + + /** + * @param bool $required + * + * @return $this + */ + public function setRequired($required) + { + $this->required = $required; + + return $this; + } + + /** + * @return string + */ + public function getFormat() + { + return $this->format; + } + + /** + * @param string $format + * + * @return $this + */ + public function setFormat($format) + { + $this->format = $format; + + return $this; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * @param mixed $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * @return array + */ + public function getChildren() + { + return $this->children; + } + + /** + * @param array $children + * + * @return $this + */ + public function setChildren(array $children) + { + $this->children = $children; + + return $this; + } +} diff --git a/Annotation/SandboxResponse.php b/Annotation/SandboxResponse.php new file mode 100644 index 0000000..338a959 --- /dev/null +++ b/Annotation/SandboxResponse.php @@ -0,0 +1,163 @@ +"), + * @Annotation\Attribute("parameters", type="array"), + * }) + */ +class SandboxResponse extends AbstractAnnotation +{ + const TYPE_JSON = 'JSON'; + const TYPE_XML = 'XML'; + + /** + * @var array|Header[] + */ + private $headers = []; + + /** + * @var array|Parameter[] + */ + private $parameters = []; + + /** + * @var integer + */ + private $statusCode = 200; + + /** + * @var string + */ + private $content; + + /** + * @var string + */ + private $type = self::TYPE_JSON; + + /** + * @return array|Header[] + */ + public function getHeaders() + { + return $this->headers; + } + + /** + * @param array|Header[] $headers + * + * @return $this + */ + public function setHeaders(array $headers) + { + $this->headers = $headers; + + return $this; + } + + /** + * @param Parameter $parameter + * + * @return $this + */ + public function addParameter(Parameter $parameter) + { + if (!in_array($parameter, $this->parameters)) { + $this->parameters[] = $parameter; + } + + return $this; + } + + /** + * @return array|Parameter[] + */ + public function getParameters() + { + return $this->parameters; + } + + /** + * @param array $parameters + * + * @return $this + */ + public function setParameters(array $parameters) + { + $this->parameters = $parameters; + + return $this; + } + + /** + * @return int + */ + public function getStatusCode() + { + return $this->statusCode; + } + + /** + * @param int $statusCode + * + * @return $this + */ + public function setStatusCode($statusCode) + { + $this->statusCode = $statusCode; + + return $this; + } + + /** + * @return string + */ + public function getContent() + { + return $this->content; + } + + /** + * @param string $content + * + * @return $this + */ + public function setContent($content) + { + $this->content = $content; + + return $this; + } + + /** + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * @param string $type + * + * @return $this + */ + public function setType($type) + { + $this->type = $type; + + return $this; + } +} diff --git a/Annotation/SandboxResponse/Header.php b/Annotation/SandboxResponse/Header.php new file mode 100644 index 0000000..7cb73a5 --- /dev/null +++ b/Annotation/SandboxResponse/Header.php @@ -0,0 +1,66 @@ +name; + } + + /** + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * @return string + */ + public function getValue() + { + return $this->value; + } + + /** + * @param string $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } +} diff --git a/ApiSandboxBundle.php b/ApiSandboxBundle.php new file mode 100644 index 0000000..2c2cf66 --- /dev/null +++ b/ApiSandboxBundle.php @@ -0,0 +1,13 @@ +processConfiguration($configuration, $configs); + + $container->setParameter('bpa_apisandbox.response.force', $config['response']['force']); + + $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); + $loader->load('services.yml'); + } +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php new file mode 100644 index 0000000..bddece7 --- /dev/null +++ b/DependencyInjection/Configuration.php @@ -0,0 +1,32 @@ +root('api_sandbox'); + + $rootNode + ->children() + ->arrayNode('response') + ->children() + ->booleanNode('force')->defaultFalse()->end() + ->end() + ->end() + ->end(); + + return $treeBuilder; + } +} diff --git a/Event/AnnotationEvent.php b/Event/AnnotationEvent.php new file mode 100644 index 0000000..cb0d26f --- /dev/null +++ b/Event/AnnotationEvent.php @@ -0,0 +1,34 @@ +annotations = $annotations; + } + + /** + * @return array + */ + public function getAnnotations() + { + return $this->annotations; + } +} diff --git a/Event/ApiSandboxEvents.php b/Event/ApiSandboxEvents.php new file mode 100644 index 0000000..4000512 --- /dev/null +++ b/Event/ApiSandboxEvents.php @@ -0,0 +1,11 @@ +getAnnotations() as $annotation) { + if ($annotation instanceof ApiDoc) { + $parameters = $this->extractParameters($annotation->getParameters()); + } + } + + foreach ($event->getAnnotations() as $annotation) { + if ($annotation instanceof SandboxRequest) { + $annotation->setParameters($parameters); + } + } + } + + /** + * @param array $parameters + * + * @return array + */ + public function extractParameters(array $parameters) + { + $sandboxParameters = []; + + foreach ($parameters as $name => $parameter) { + $attributes = [ + 'name' => $name, + ]; + + if (isset($parameter['required'])) { + $attributes['required'] = $parameter['required']; + } + + if (isset($parameter['dataType'])) { + $attributes['type'] = $parameter['dataType']; + } + + if (isset($parameter['format'])) { + $attributes['format'] = $parameter['format']; + } + + if (isset($parameter['children'])) { + $attributes['type'] = SandboxRequest\Parameter::TYPE_ARRAY; + $attributes['children'] = $this->extractParameters($parameter['children']); + } + + $sandboxParameter = new SandboxRequest\Parameter($attributes); + + if (isset($attributes['children'])) { + $sandboxParameter->setChildren($attributes['children']); + } + + $sandboxParameters[] = $sandboxParameter; + } + + return $sandboxParameters; + } +} diff --git a/EventListener/SandboxControllerListener.php b/EventListener/SandboxControllerListener.php new file mode 100644 index 0000000..d88c575 --- /dev/null +++ b/EventListener/SandboxControllerListener.php @@ -0,0 +1,39 @@ +service = $service; + } + + /** + * @param FilterControllerEvent $event + */ + public function onKernelController(FilterControllerEvent $event) + { + list($controller, $method) = $event->getController(); + + $controller = $this->service->getSandboxController($controller, $method); + + $event->setController($controller); + } +} diff --git a/Resources/config/services.yml b/Resources/config/services.yml new file mode 100644 index 0000000..b4b0a8d --- /dev/null +++ b/Resources/config/services.yml @@ -0,0 +1,22 @@ +services: + bpa_sandbox.controller_listener: + class: 'Bpa\ApiSandboxBundle\EventListener\SandboxControllerListener' + arguments: + - '@bpa_sandbox.controller_service' + - '@request_stack' + tags: + - { name: 'kernel.event_listener', event: 'kernel.controller', method: 'onKernelController' } + + bpa_sandbox.controller_service: + class: 'Bpa\ApiSandboxBundle\Service\ControllerService' + arguments: + - '@request_stack' + - '@annotations.reader' + - '@event_dispatcher' + - '@file_locator' + - '%api_sandbox.response.force' + + bpa_sandbox.apidoc_listener: + class: 'Bpa\ApiSandboxBundle\EventListener\ApiDocListener' + tags: + - { name: 'kernel.event_listener', event: 'annotations.loaded', method: 'onAnnotationsLoaded' } diff --git a/Service/ControllerService.php b/Service/ControllerService.php new file mode 100644 index 0000000..06b80db --- /dev/null +++ b/Service/ControllerService.php @@ -0,0 +1,233 @@ +requestStack = $requestStack; + $this->annotationReader = $annotationReader; + $this->dispatcher = $dispatcher; + $this->fileLocator = $fileLocator; + $this->forceSandbox = $forceSandbox; + } + + /** + * @param object $controller + * @param string $method + * + * @return callable + */ + public function getSandboxController($controller, $method) + { + if (null === $annotation = $this->getMatchingResponseAnnotation($controller, $method)) { + if ($this->forceSandbox) { + throw new HttpException( + Response::HTTP_BAD_REQUEST, + 'Could not find any matching sandbox response' + ); + } else { + return [$controller, $method]; + } + } + + if (null !== $content = $annotation->getContent()) { + if (substr($content, 0, 1) == '@') { + $path = $this->fileLocator->locate($content); + $content = file_get_contents($path); + } + } + + $headers = []; + foreach ($annotation->getHeaders() as $header) { + $headers[$header->getName()] = $header->getValue(); + } + + switch ($annotation->getType()) { + case SandboxResponse::TYPE_XML: + $class = Response::class; + break; + + default: + $class = JsonResponse::class; + $content = json_decode($content, true); + break; + } + + $response = new $class($content, $annotation->getStatusCode(), $headers); + + return function() use ($response) { + return $response; + }; + } + + /** + * @param object $controller + * @param string $method + * + * @return null|SandboxResponse + */ + private function getMatchingResponseAnnotation($controller, $method) + { + $method = new \ReflectionMethod($controller, $method); + + $annotations = $this->annotationReader->getMethodAnnotations($method); + + $event = new AnnotationEvent($annotations); + $this->dispatcher->dispatch(ApiSandboxEvents::ANNOTATIONS_LOADED, $event); + + foreach ($annotations as $annotation) { + if ($annotation instanceof SandboxRequest) { + foreach ($annotation->getResponses() as $response) { + if ($this->isResponseMatching($annotation, $response)) { + return $response; + } + } + } + } + + return null; + } + + /** + * @param SandboxRequest $request + * @param SandboxResponse $response + * + * @return bool + */ + private function isResponseMatching(SandboxRequest $request, SandboxResponse $response) + { + $currentRequest = $this->requestStack->getCurrentRequest(); + + /** @var SandboxRequest\Parameter[] $parameters */ + $parameters = $this->mergeParameters($request, $response); + + foreach ($parameters as $parameter) { + $value = $currentRequest->get($parameter->getName(), null); + + if ($parameter->isRequired() && null === $value) { + throw new HttpException( + Response::HTTP_INTERNAL_SERVER_ERROR, + sprintf('Parameter "%s" is missing.', $parameter->getName()) + ); + } + + if (null !== $parameter->getValue()) { + if ($value != $parameter->getValue()) { + return false; + } + } + + if (null !== $format = $parameter->getFormat() && null !== $value) { + if (!preg_match('@'.$format.'@', $value)) { + throw new HttpException( + Response::HTTP_INTERNAL_SERVER_ERROR, + sprintf( + 'Value "%s" for parameter "%s" does not match format "%s"', + $value, + $parameter->getName(), + $value + ) + ); + } + } + + } + + return true; + } + + /** + * @param SandboxRequest $request + * @param SandboxResponse $response + * + * @return array + */ + private function mergeParameters(SandboxRequest $request, SandboxResponse $response) + { + $parameters = []; + + // Get the default parameters from the request + foreach ($request->getParameters() as $parameter) { + $parameters[$parameter->getName()] = $parameter; + } + + // Override any default parameters from the request with those from the response + foreach ($response->getParameters() as $parameter) { + if (isset($parameters[$parameter->getName()])) { + $original = $parameters[$parameter->getName()]; + $methods = get_class_methods($parameter); + + foreach ($methods as $method) { + if (preg_match('@(?get(?.+))@', $method, $matches)) { + $setter = 'set'.ucfirst($matches['name']); + $getter = $matches['getter']; + + if (null !== $value = $parameter->$getter()) { + $original->$setter($value); + } + } + } + } else { + $parameters[$parameter->getName()] = $parameter; + } + } + + return $parameters; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..32e6642 --- /dev/null +++ b/composer.json @@ -0,0 +1,12 @@ +{ + "name": "bpa/api-sandbox-bundle", + "description": "Symfony Bundle for easy creation of API Sandboxes and integration with NelmioApiDocBundle", + "type": "library", + "authors": [ + { + "name": "Benjamin Paap", + "email": "benjamin.paap@gmail.com" + } + ], + "require": {} +}