diff --git a/src/Handler/ValidatedDescriptionHandler.php b/src/Handler/ValidatedDescriptionHandler.php index c5ec2d6e..89641709 100644 --- a/src/Handler/ValidatedDescriptionHandler.php +++ b/src/Handler/ValidatedDescriptionHandler.php @@ -3,6 +3,7 @@ use GuzzleHttp\Command\CommandInterface; use GuzzleHttp\Command\Exception\CommandException; use GuzzleHttp\Command\Guzzle\DescriptionInterface; +use GuzzleHttp\Command\Guzzle\Parameter; use GuzzleHttp\Command\Guzzle\SchemaValidator; /** @@ -43,17 +44,30 @@ public function __invoke(callable $handler) foreach ($operation->getParams() as $name => $schema) { $value = $command[$name]; - if ($value) { - $value = $schema->filter($value); - } + $preValidationValue = $schema->filter( + $value, + Parameter::FILTER_STAGE_BEFORE_VALIDATION + ); + + if (!$this->validator->validate($schema, $preValidationValue)) { + $errors = + array_merge($errors, $this->validator->getErrors()); + } else { + $postValidationValue = $schema->filter( + $preValidationValue, + Parameter::FILTER_STAGE_AFTER_VALIDATION + ); - if (! $this->validator->validate($schema, $value)) { - $errors = array_merge($errors, $this->validator->getErrors()); - } elseif ($value !== $command[$name]) { - // Update the config value if it changed and no validation errors were encountered. - // This happen when the user extending an operation - // See https://github.com/guzzle/guzzle-services/issues/145 - $command[$name] = $value; + if ($postValidationValue !== $command[$name]) { + // Update the parameter value if it has changed and no + // validation errors were encountered. This ensures the + // parameter has a value even when the user is extending + // an operation. + // + // See: + // https://github.com/guzzle/guzzle-services/issues/145 + $command[$name] = $postValidationValue; + } } } diff --git a/src/Operation.php b/src/Operation.php index 57b75ca2..9739c005 100644 --- a/src/Operation.php +++ b/src/Operation.php @@ -53,7 +53,7 @@ public function __construct(array $config = [], DescriptionInterface $descriptio { static $defaults = [ 'name' => '', - 'httpMethod' => '', + 'httpMethod' => 'GET', 'uri' => '', 'responseModel' => null, 'notes' => '', @@ -72,6 +72,10 @@ public function __construct(array $config = [], DescriptionInterface $descriptio $config = $this->resolveExtends($config['extends'], $config); } + if (array_key_exists('httpMethod', $config) && empty($config['httpMethod'])) { + throw new \InvalidArgumentException('httpMethod must be a non-empty string'); + } + $this->config = $config + $defaults; // Account for the old style of using responseClass diff --git a/src/Parameter.php b/src/Parameter.php index 8b3c39f2..03edf160 100644 --- a/src/Parameter.php +++ b/src/Parameter.php @@ -8,6 +8,43 @@ */ class Parameter implements ToArrayInterface { + /** + * The name of the filter stage that happens before a parameter is + * validated, for filtering raw data (e.g. clean-up before validation). + */ + const FILTER_STAGE_BEFORE_VALIDATION = 'before_validation'; + + /** + * The name of the filter stage that happens immediately after a parameter + * has been validated but before it is evaluated by location handlers to be + * written out on the wire. + */ + const FILTER_STAGE_AFTER_VALIDATION = 'after_validation'; + + /** + * The name of the filter stage that happens right before a validated value + * is being written out "on the wire" (e.g. for adjusting the structure or + * format of the data before sending it to the server). + */ + const FILTER_STAGE_REQUEST_WIRE = 'request_wire'; + + /** + * The name of the filter stage that happens right after a value has been + * read out of a response "on the wire" (e.g. for adjusting the structure or + * format of the data after receiving it back from the server). + */ + const FILTER_STAGE_RESPONSE_WIRE = 'response_wire'; + + /** + * A list of all allowed filter stages. + */ + const FILTER_STAGES = [ + self::FILTER_STAGE_BEFORE_VALIDATION, + self::FILTER_STAGE_AFTER_VALIDATION, + self::FILTER_STAGE_REQUEST_WIRE, + self::FILTER_STAGE_RESPONSE_WIRE + ]; + private $originalData; /** @var string $name */ @@ -117,14 +154,23 @@ class Parameter implements ToArrayInterface * full class path to a static method or an array of complex filter * information. You can specify static methods of classes using the full * namespace class name followed by '::' (e.g. Foo\Bar::baz). Some - * filters require arguments in order to properly filter a value. For - * complex filters, use a hash containing a 'method' key pointing to a - * static method, and an 'args' key containing an array of positional - * arguments to pass to the method. Arguments can contain keywords that - * are replaced when filtering a value: '@value' is replaced with the - * value being validated, '@api' is replaced with the Parameter object. + * filters require arguments in order to properly filter a value. * - * - properties: When the type is an object, you can specify nested parameters + * For complex filters, use a hash containing a 'method' key pointing to a + * static method, an 'args' key containing an array of positional + * arguments to pass to the method, and an optional 'stage' key. Arguments + * can contain keywords that are replaced when filtering a value: '@value' + * is replaced with the value being validated, '@api' is replaced with the + * Parameter object, and '@stage' is replaced with the current filter + * stage (if any was provided). + * + * The optional 'stage' key can be provided to control when the filter is + * invoked. The key can indicate that a filter should only be invoked + * 'before_validation', 'after_validation', when being written out to the + * 'request_wire' or being read from the 'response_wire'. + * + * - properties: When the type is an object, you can specify nested + * parameters * * - additionalProperties: (array) This attribute defines a schema for all * properties that are not explicitly defined in an object type @@ -210,7 +256,9 @@ public function __construct(array $data = [], array $options = []) $this->required = (bool) $this->required; $this->data = (array) $this->data; - if ($this->filters) { + if (empty($this->filters)) { + $this->filters = []; + } else { $this->setFilters((array) $this->filters); } @@ -250,15 +298,31 @@ public function getValue($value) * parameter. * * @param mixed $value Value to filter + * @param string $stage An optional specifier of what filter stage to + * invoke. If null, then all filters are invoked no matter what stage + * they apply to. Otherwise, only filters for the specified stage are + * invoked. * * @return mixed Returns the filtered value * @throws \RuntimeException when trying to format when no service * description is available. - */ - public function filter($value) - { - // Formats are applied exclusively and supersed filters - if ($this->format) { + * @throws \InvalidArgumentException if an invalid validation stage is + * provided. + */ + public function filter($value, $stage = null) + { + if (($stage !== null) && !in_array($stage, self::FILTER_STAGES)) { + throw new \InvalidArgumentException( + sprintf( + '$stage must be one of [%s], but was given "%s"', + implode(', ', self::FILTER_STAGES), + $stage + ) + ); + } + + // Formats are applied exclusively and supercede filters + if (!empty($this->format)) { if (!$this->serviceDescription) { throw new \RuntimeException('No service description was set so ' . 'the value cannot be formatted.'); @@ -272,25 +336,8 @@ public function filter($value) } // Apply filters to the value - if ($this->filters) { - foreach ($this->filters as $filter) { - if (is_array($filter)) { - // Convert complex filters that hold value place holders - foreach ($filter['args'] as &$data) { - if ($data == '@value') { - $data = $value; - } elseif ($data == '@api') { - $data = $this; - } - } - $value = call_user_func_array( - $filter['method'], - $filter['args'] - ); - } else { - $value = call_user_func($filter, $value); - } - } + if (!empty($this->filters)) { + $value = $this->invokeCustomFilters($value, $stage); } return $value; @@ -487,7 +534,7 @@ public function isStatic() */ public function getFilters() { - return $this->filters ?: []; + return $this->filters; } /** @@ -605,6 +652,7 @@ public function getFormat() private function setFilters(array $filters) { $this->filters = []; + foreach ($filters as $filter) { $this->addFilter($filter); } @@ -628,14 +676,21 @@ private function addFilter($filter) 'A [method] value must be specified for each complex filter' ); } - } - if (!$this->filters) { - $this->filters = [$filter]; - } else { - $this->filters[] = $filter; + if (isset($filter['stage']) + && !in_array($filter['stage'], self::FILTER_STAGES)) { + throw new \InvalidArgumentException( + sprintf( + '[stage] value must be one of [%s], but was given "%s"', + implode(', ', self::FILTER_STAGES), + $filter['stage'] + ) + ); + } } + $this->filters[] = $filter; + return $this; } @@ -652,4 +707,127 @@ public function has($var) } return isset($this->{$var}) && !empty($this->{$var}); } + + /** + * Filters the given data using filter methods specified in the config. + * + * If $stage is provided, only filters that apply to the provided filter + * stage will be invoked. To preserve legacy behavior, filters that do not + * specify a stage are implicitly invoked only in the pre-validation stage. + * + * @param mixed $value The value to filter. + * @param string $stage An optional specifier of what filter stage to + * invoke. If null, then all filters are invoked no matter what stage + * they apply to. Otherwise, only filters for the specified stage are + * invoked. + * + * @return mixed The filtered value. + */ + private function invokeCustomFilters($value, $stage) { + $filteredValue = $value; + + foreach ($this->filters as $filter) { + if (is_array($filter)) { + $filteredValue = + $this->invokeComplexFilter($filter, $value, $stage); + } else { + $filteredValue = + $this->invokeSimpleFilter($filter, $value, $stage); + } + } + + return $filteredValue; + } + + /** + * Invokes a filter that uses value substitution and/or should only be + * invoked for a particular filter stage. + * + * If $stage is provided, and the filter specifies a stage, it is not + * invoked unless $stage matches the stage the filter indicates it applies + * to. If the filter is not invoked, $value is returned exactly as it was + * provided to this method. + * + * To preserve legacy behavior, if the filter does not specify a stage, it + * is implicitly invoked only in the pre-validation stage. + * + * @param array $filter Information about the filter to invoke. + * @param mixed $value The value to filter. + * @param string $stage An optional specifier of what filter stage to + * invoke. If null, then the filter is invoked no matter what stage it + * indicates it applies to. Otherwise, the filter is only invoked if it + * matches the specified stage. + * + * @return mixed The filtered value. + */ + private function invokeComplexFilter(array $filter, $value, $stage) { + if (isset($filter['stage'])) { + $filterStage = $filter['stage']; + } else { + $filterStage = self::FILTER_STAGE_AFTER_VALIDATION; + } + + if (($stage === null) || ($filterStage == $stage)) { + // Convert complex filters that hold value place holders + $filterArgs = + $this->expandFilterArgs($filter['args'], $value, $stage); + + $filteredValue = + call_user_func_array($filter['method'], $filterArgs); + } else { + $filteredValue = $value; + } + + return $filteredValue; + } + + /** + * Replaces any placeholders in filter arguments with values from the + * current context. + * + * @param array $filterArgs The array of arguments to pass to the filter + * function. Some of the elements of this array are expected to be + * placeholders that will be replaced by this function. + * + * @return array The array of arguments, with all placeholders replaced. + */ + private function expandFilterArgs(array $filterArgs, $value, $stage) { + $replacements = [ + '@value' => $value, + '@api' => $this, + '@stage' => $stage, + ]; + + foreach ($filterArgs as &$argValue) { + if (isset($replacements[$argValue])) { + $argValue = $replacements[$argValue]; + } + } + + return $filterArgs; + } + + /** + * Invokes a filter only provides a function or method name to invoke, + * without additional parameters. + * + * If $stage is provided, the filter is not invoked unless we are in the + * pre-validation stage, to preserve legacy behavior. + * + * @param array $filter Information about the filter to invoke. + * @param mixed $value The value to filter. + * @param string $stage An optional specifier of what filter stage to + * invoke. If null, then the filter is invoked no matter what. + * Otherwise, the filter is only invoked if the value is + * FILTER_STAGE_AFTER_VALIDATION. + * + * @return mixed The filtered value. + */ + private function invokeSimpleFilter($filter, $value, $stage) { + if ($stage === self::FILTER_STAGE_AFTER_VALIDATION) { + return $value; + } else { + return call_user_func($filter, $value); + } + } } diff --git a/src/RequestLocation/AbstractLocation.php b/src/RequestLocation/AbstractLocation.php index 29b484b0..4790f23c 100644 --- a/src/RequestLocation/AbstractLocation.php +++ b/src/RequestLocation/AbstractLocation.php @@ -62,7 +62,7 @@ protected function prepareValue($value, Parameter $param) { return is_array($value) ? $this->resolveRecursively($value, $param) - : $param->filter($value); + : $param->filter($value, Parameter::FILTER_STAGE_REQUEST_WIRE); } /** @@ -96,6 +96,6 @@ protected function resolveRecursively(array $value, Parameter $param) } } - return $param->filter($value); + return $param->filter($value, Parameter::FILTER_STAGE_REQUEST_WIRE); } } diff --git a/src/RequestLocation/BodyLocation.php b/src/RequestLocation/BodyLocation.php index aef4eb00..c7bf98ac 100644 --- a/src/RequestLocation/BodyLocation.php +++ b/src/RequestLocation/BodyLocation.php @@ -35,15 +35,20 @@ public function visit( RequestInterface $request, Parameter $param ) { - $oldValue = $request->getBody()->getContents(); + $existingResponse = $request->getBody()->getContents(); $value = $command[$param->getName()]; - $value = $param->getName() . '=' . $param->filter($value); + $filteredValue = + $param->filter($value, Parameter::FILTER_STAGE_REQUEST_WIRE); - if ($oldValue !== '') { - $value = $oldValue . '&' . $value; + $valueForResponse = sprintf('%s=%s', $param->getName(), $filteredValue); + + if ($existingResponse == '') { + $response = $valueForResponse; + } else { + $response = $existingResponse . '&' . $valueForResponse; } - return $request->withBody(Psr7\stream_for($value)); + return $request->withBody(Psr7\stream_for($response)); } } diff --git a/src/RequestLocation/HeaderLocation.php b/src/RequestLocation/HeaderLocation.php index cb067c46..b16e1de1 100644 --- a/src/RequestLocation/HeaderLocation.php +++ b/src/RequestLocation/HeaderLocation.php @@ -36,8 +36,10 @@ public function visit( Parameter $param ) { $value = $command[$param->getName()]; + $filteredValue = + $param->filter($value, Parameter::FILTER_STAGE_REQUEST_WIRE); - return $request->withHeader($param->getWireName(), $param->filter($value)); + return $request->withHeader($param->getWireName(), $filteredValue); } /** @@ -57,7 +59,12 @@ public function after( if ($additional && ($additional->getLocation() === $this->locationName)) { foreach ($command->toArray() as $key => $value) { if (!$operation->hasParam($key)) { - $request = $request->withHeader($key, $additional->filter($value)); + $filteredValue = $additional->filter( + $value, + Parameter::FILTER_STAGE_REQUEST_WIRE + ); + + $request = $request->withHeader($key, $filteredValue); } } } diff --git a/src/RequestLocation/XmlLocation.php b/src/RequestLocation/XmlLocation.php index cadbd2e7..3d8ee306 100644 --- a/src/RequestLocation/XmlLocation.php +++ b/src/RequestLocation/XmlLocation.php @@ -154,7 +154,8 @@ protected function createRootElement(Operation $operation) */ protected function addXml(\XMLWriter $writer, Parameter $param, $value) { - $value = $param->filter($value); + $filteredValue = + $param->filter($value, Parameter::FILTER_STAGE_REQUEST_WIRE); $type = $param->getType(); $name = $param->getWireName(); $prefix = null; @@ -172,9 +173,9 @@ protected function addXml(\XMLWriter $writer, Parameter $param, $value) } } if ($param->getType() == 'array') { - $this->addXmlArray($writer, $param, $value); + $this->addXmlArray($writer, $param, $filteredValue); } elseif ($param->getType() == 'object') { - $this->addXmlObject($writer, $param, $value); + $this->addXmlObject($writer, $param, $filteredValue); } if (!$param->getData('xmlFlattened')) { $writer->endElement(); @@ -182,9 +183,21 @@ protected function addXml(\XMLWriter $writer, Parameter $param, $value) return; } if ($param->getData('xmlAttribute')) { - $this->writeAttribute($writer, $prefix, $name, $namespace, $value); + $this->writeAttribute( + $writer, + $prefix, + $name, + $namespace, + $filteredValue + ); } else { - $this->writeElement($writer, $prefix, $name, $namespace, $value); + $this->writeElement( + $writer, + $prefix, + $name, + $namespace, + $filteredValue + ); } } diff --git a/src/ResponseLocation/BodyLocation.php b/src/ResponseLocation/BodyLocation.php index f21d60a8..93cec144 100644 --- a/src/ResponseLocation/BodyLocation.php +++ b/src/ResponseLocation/BodyLocation.php @@ -32,7 +32,11 @@ public function visit( ResponseInterface $response, Parameter $param ) { - $result[$param->getName()] = $param->filter($response->getBody()); + $value = $response->getBody(); + $filteredValue = + $param->filter($value, Parameter::FILTER_STAGE_RESPONSE_WIRE); + + $result[$param->getName()] = $filteredValue; return $result; } diff --git a/src/ResponseLocation/HeaderLocation.php b/src/ResponseLocation/HeaderLocation.php index d156aff1..bca0f644 100644 --- a/src/ResponseLocation/HeaderLocation.php +++ b/src/ResponseLocation/HeaderLocation.php @@ -39,7 +39,11 @@ public function visit( if (is_array($header)) { $header = array_shift($header); } - $result[$name] = $param->filter($header); + + $filteredHeader = + $param->filter($header, Parameter::FILTER_STAGE_RESPONSE_WIRE); + + $result[$name] = $filteredHeader; } return $result; diff --git a/src/ResponseLocation/JsonLocation.php b/src/ResponseLocation/JsonLocation.php index f94c7844..e67174b5 100644 --- a/src/ResponseLocation/JsonLocation.php +++ b/src/ResponseLocation/JsonLocation.php @@ -128,7 +128,10 @@ public function visit( private function recurse(Parameter $param, $value) { if (!is_array($value)) { - return $param->filter($value); + $filteredValue = + $param->filter($value, Parameter::FILTER_STAGE_RESPONSE_WIRE); + + return $filteredValue; } $result = []; @@ -171,6 +174,6 @@ private function recurse(Parameter $param, $value) } } - return $param->filter($result); + return $param->filter($result, Parameter::FILTER_STAGE_RESPONSE_WIRE); } } diff --git a/src/ResponseLocation/ReasonPhraseLocation.php b/src/ResponseLocation/ReasonPhraseLocation.php index 1cb590ff..2dfcedd3 100644 --- a/src/ResponseLocation/ReasonPhraseLocation.php +++ b/src/ResponseLocation/ReasonPhraseLocation.php @@ -32,9 +32,11 @@ public function visit( ResponseInterface $response, Parameter $param ) { - $result[$param->getName()] = $param->filter( - $response->getReasonPhrase() - ); + $value = $response->getReasonPhrase(); + $filteredValue = + $param->filter($value, Parameter::FILTER_STAGE_RESPONSE_WIRE); + + $result[$param->getName()] = $filteredValue; return $result; } diff --git a/src/ResponseLocation/StatusCodeLocation.php b/src/ResponseLocation/StatusCodeLocation.php index eaef3b04..d0d2df84 100644 --- a/src/ResponseLocation/StatusCodeLocation.php +++ b/src/ResponseLocation/StatusCodeLocation.php @@ -32,7 +32,11 @@ public function visit( ResponseInterface $response, Parameter $param ) { - $result[$param->getName()] = $param->filter($response->getStatusCode()); + $value = $response->getStatusCode(); + $filteredValue = + $param->filter($value, Parameter::FILTER_STAGE_RESPONSE_WIRE); + + $result[$param->getName()] = $filteredValue; return $result; } diff --git a/src/ResponseLocation/XmlLocation.php b/src/ResponseLocation/XmlLocation.php index 94509098..503f5267 100644 --- a/src/ResponseLocation/XmlLocation.php +++ b/src/ResponseLocation/XmlLocation.php @@ -125,7 +125,8 @@ private function recursiveProcess( // Filter out the value if (isset($result)) { - $result = $param->filter($result); + $result = + $param->filter($result, Parameter::FILTER_STAGE_RESPONSE_WIRE); } return $result; diff --git a/src/Serializer.php b/src/Serializer.php index 160fbc25..f90a63ea 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -145,9 +145,15 @@ private function createCommandWithUri( /* @var Parameter $arg */ if ($arg->getLocation() == 'uri') { if (isset($command[$name])) { - $variables[$name] = $arg->filter($command[$name]); - if (!is_array($variables[$name])) { - $variables[$name] = (string) $variables[$name]; + $filteredValue = $arg->filter( + $command[$name], + Parameter::FILTER_STAGE_REQUEST_WIRE + ); + + if (is_array($filteredValue)) { + $variables[$name] = $filteredValue; + } else { + $variables[$name] = (string)$filteredValue; } } } diff --git a/tests/OperationTest.php b/tests/OperationTest.php index 04313dd7..007a9ad4 100644 --- a/tests/OperationTest.php +++ b/tests/OperationTest.php @@ -122,6 +122,27 @@ public function testHasData() $this->assertEquals(['foo' => 'baz', 'bar' => 123], $o->getData()); } + public function testDefaultsHttpMethodToGET() + { + $o = new Operation(); + $this->assertEquals('GET', $o->getHttpMethod()); + } + + public function testCanProvideAlternateHttpMethod() + { + $o = new Operation(['httpMethod' => 'POST']); + $this->assertEquals('POST', $o->getHttpMethod()); + } + + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMesssage httpMethod must be a non-empty string + */ + public function testEnsuresHttpMethodIsNotEmptyString() + { + new Operation(['httpMethod' => '']); + } + /** * @expectedException \InvalidArgumentException * @expectedExceptionMesssage Parameters must be arrays @@ -196,6 +217,7 @@ public function testCanExtendFromOtherOperations() 'summary' => 'foo' ], 'B' => [ + 'httpMethod' => 'POST', 'extends' => 'A', 'summary' => 'Bar' ], @@ -210,17 +232,20 @@ public function testCanExtendFromOtherOperations() ]); $a = $d->getOperation('A'); + $this->assertEquals('GET', $a->getHttpMethod()); $this->assertEquals('foo', $a->getSummary()); $this->assertTrue($a->hasParam('A')); $this->assertEquals('string', $a->getParam('B')->getType()); $b = $d->getOperation('B'); $this->assertTrue($a->hasParam('A')); + $this->assertEquals('POST', $b->getHttpMethod()); $this->assertEquals('Bar', $b->getSummary()); $this->assertEquals('string', $a->getParam('B')->getType()); $c = $d->getOperation('C'); $this->assertTrue($a->hasParam('A')); + $this->assertEquals('POST', $c->getHttpMethod()); $this->assertEquals('Bar', $c->getSummary()); $this->assertEquals('number', $c->getParam('B')->getType()); }