Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Query param fetcher #185

Merged
merged 49 commits into from
@lsmith77
Owner

maybe instead of a listener we can use a param converter:
http://symfony.com/doc/2.0/bundles/SensioFrameworkExtraBundle/annotations/converters.html

@stof
Owner

does the QueryParam stuff depend on FrameworkExtraBundle currently ?

@lsmith77
Owner

no .. currently it does not. but i don't really like having to use a listener to set the request attribute.

Request/QueryFetcher.php
((52 lines not shown))
+ public function __construct(ContainerInterface $container, QueryParamReader $queryParamReader, Request $request)
+ {
+ $this->container = $container;
+ $this->queryParamReader = $queryParamReader;
+ $this->request = $request;
+ }
+
+ private function initParams()
+ {
+ $_controller = $this->request->attributes->get('_controller');
+
+ if (null === $_controller) {
+ throw new \InvalidArgumentException('No _controller for request.');
+ }
+
+ if (false !== strpos($_controller, '::')) {
@vicb
vicb added a note

Could you leverage the ControllerResolver ?

@lsmith77 Owner
lsmith77 added a note

yeah maybe ..

@lsmith77 Owner
lsmith77 added a note

looking at the code it seems like it would be inefficient for the case when the controller is not implemented as a service. but it looks like we are lacking support for a:b:c notation atm.

@vicb
vicb added a note

That would be more dry and future proof

@lsmith77 Owner
lsmith77 added a note

ok .. we should now cover all the cases

@lsmith77 Owner
lsmith77 added a note

yeah it would .. but i don't think the overhead makes this legitimate.

@vicb
vicb added a note

could the controller event help with efficiency ?

@lsmith77 Owner
lsmith77 added a note

hmm it could be a possibility. in that case we would move the other listener from a request listener to a controller listener as well. in that case of course the parameters would no longer be available in any listeners run before the controller, but i guess that would be ok.

@lsmith77 Owner
lsmith77 added a note

ok .. i have an implementation using a controller event .. will push that into a new branch

@lsmith77 Owner
lsmith77 added a note

see #186

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Request/QueryFetcher.php
((88 lines not shown))
+ */
+ public function getParameter($name)
+ {
+ if (!isset($this->params)) {
+ $this->initParams();
+ }
+
+ if (!isset($this->params[$name])) {
+ throw new \InvalidArgumentException(sprintf("No @QueryParam configuration for parameter '%s'.", $name));
+ }
+
+ $param = $this->request->query->get($name, $this->params[$name]->default);
+
+ // Set default if the requirements do not match
+ if ($param !== $this->params[$name]->default
+ && !preg_match('/^' . $this->params[$name]->requirements . '/xs', $param)
@vicb
vicb added a note

is '$' missing on purpose ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Request/QueryFetcher.php
((90 lines not shown))
+ {
+ if (!isset($this->params)) {
+ $this->initParams();
+ }
+
+ if (!isset($this->params[$name])) {
+ throw new \InvalidArgumentException(sprintf("No @QueryParam configuration for parameter '%s'.", $name));
+ }
+
+ $param = $this->request->query->get($name, $this->params[$name]->default);
+
+ // Set default if the requirements do not match
+ if ($param !== $this->params[$name]->default
+ && !preg_match('/^' . $this->params[$name]->requirements . '/xs', $param)
+ ) {
+ $param = $this->params[$name]->default;
@vicb
vicb added a note

factorize $default

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Request/QueryFetcher.php
((84 lines not shown))
+ *
+ * @param string $name Name of the query parameter
+ *
+ * @return mixed Value of the parameter.
+ */
+ public function getParameter($name)
+ {
+ if (!isset($this->params)) {
+ $this->initParams();
+ }
+
+ if (!isset($this->params[$name])) {
+ throw new \InvalidArgumentException(sprintf("No @QueryParam configuration for parameter '%s'.", $name));
+ }
+
+ $param = $this->request->query->get($name, $this->params[$name]->default);
@vicb
vicb added a note

using has() would allow always validating against the requirement (thinking of the default value here but not sure of what is the desired behavior)

@lsmith77 Owner
lsmith77 added a note

that $param !== $default further down is just a performance optimization to avoid the regexp if it isn't needed. so i think the code is ok as is.

@vicb
vicb added a note

just wanted to make sure you were aware of the edge case here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
EventListener/QueryFetcherListener.php
((33 lines not shown))
+ * @param ContainerInterface $container container
+ */
+ public function __construct(ContainerInterface $container)
+ {
+ $this->container = $container;
+ }
+
+ /**
+ * Core request handler
+ *
+ * @param GetResponseEvent $event The event
+ */
+ public function onKernelRequest(GetResponseEvent $event)
+ {
+ $request = $event->getRequest();
+ $request->attributes->set('queryFetcher', $this->container->get('fos_rest.request.query_fetcher'));
@vicb
vicb added a note

do we really need a listener, can't it be lazy loaded (just a thought, too late to check for now)

@lsmith77 Owner
lsmith77 added a note

we need a listener to set the request attribute, so that it can be injected via the action signature, which is imperative to controllers that do not inject the DIC ..

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@stof

closures and fonctions won't be catched here as they are not arrays. The first if should only check for null (or eventually for is_callable if you want)

@vicb

Some thoughts:

Is the queryFetcher a good idea ? Let's imagine I do some processing in my controller based on the query parameters and then I call doSomeGreatStuff($request). This function would get the query parameters as $request->query->... and then we have a mismatch. Would it be possible to drop the queryFetcher and replace (or update) the query parameter bag instead so that the API does not get changed ?

I can imagine some day we have a router component that is aware of the query parameters. This PR could not work as the parameters are processed in the controller only. So I would prefer if the changes are moved to the router layer.

@lsmith77
Owner

Overriding the Request parameter bag would remove the need to set the request attribute. How would it behave in that case though? I guess it would then only overload get() to set the default from the annotation (what would happen if a default is passed?) and execute the given requirements checks from the annotation?

@lsmith77
Owner

the other question would be where and when do we override the default parameter bag? would this require a custom Request class or a listener again?

@vicb

@lsmith77

  • What I can imagine is to update the parameters in the bag and then use the bag as before.
  • I would go for the router layer for the reason explained in my previous message. I had first thought of a custom bag class but I don't even think it is necessary (see my point above).
@lsmith77
Owner

Well I see some major issues here:
1) it would mean we do a lot of work that may not become necessary
2) if we do it in the router, then we will have the issue with determining the class/method again

@vicb

Well maybe the best place is the controller event. IMO the most important thing is to drop the $queryFetcher and be able to use doSomeGreatStuff($request). I let you work out the details.

@lsmith77
Owner

If we do this, then we would best wrap the standard ParameterBag inside one that automatically sets the defaults and checks the requirements when any of the get*() methods are called. However this may also lead to a bit of unclearity as to when these requirements are applied. Aka sometimes when you use the request it would apply the requirements .. but for example inside a request listener they would not. I think this makes things more obscure, so I prefer the current approach.

@vicb

That might indeed be a bad idea but the current implementation is even worse then. The solution would be to change to router to support matching query params.

@lsmith77
Owner

I don't see the current implementation as worse at all. It just means that adopting this approach requires to change some API calls.

Now changing the router is of course another option. But I am not sure if this is possible without either not supporting all the dumpers or increasing the scope of this PR to require a gigantic amount of work. Then again I guess we will get this once we have uri-template support:
http://tools.ietf.org/html/draft-gregorio-uritemplate-08
symfony/symfony#3227

@lsmith77
Owner

Btw .. in this case we would of course get around the entire controller setting business, since we would add this information into the routes. Thinking about it .. maybe we could do this today as well? Aka instead of reading the annotations at runtime, we could integrate the query param annotations into the rest route loader and write this information in there somewhere (not sure if its possible) and then read it out in the query fetcher ..

@vicb

I think you mentioned that the code should work after re-factoring. Keeping the $queryFetcher prevent this, right ? Passing it as a parameter does not solve the solution either: it would introduce coupling and legacy (/3rd party) services would not work.

@lsmith77
Owner

Prevents what? Which refactoring do you mean specifically?

@vicb

Moving some code from an action to a service (i.e. doSomeGreatStuff($request)). Wasn't that you were speaking about.
Anyway the other part doSomeLegacyStuff($request), doSome3rdPartyStuff($request) is still valid.

@lsmith77
Owner

Ah yes. Any service after the controller listener can inject the query fetcher as it is now and things will work cleanly. Any server before the controller listener will get an exception. So no surprises.

@gimler

any news?

@lsmith77
Owner

basically we have decided we want to move the matching of the query parameters to the routing layer, which would be the case once we have full support for uri-templates. now the question is if we still merge the current state and then simply break BC once uri-template support is available. or one of us works on uri-template support. should not be toooo hard and would be a huge boost for Symfony2 in general.

@ibolmo

Could you piggy back on @Route?

@lsmith77
Owner

i have been thinking some more about this over the weekend also in regards to uri-templates. one thing that i finally realized is that path parameters requirements will need to be handled differently than query parameters requirements. especially for the later it should not prevent a route match and it will usually require custom code to handle errors.

CC @Tobion

@lsmith77
Owner

ok .. unless someone brings up a new concern .. i will soon merge this PR

Controller/Annotations/QueryParam.php
((10 lines not shown))
+ */
+
+namespace FOS\RestBundle\Controller\Annotations;
+
+/**
+ * QueryParam annotation class.
+ *
+ * @Annotation
+ * @author Alexander <iam.asm89@gmail.com>
+ */
+class QueryParam
+{
+ public $name;
+ public $requirements;
+ public $default;
+ public $description;
@stof Owner
stof added a note

you should probably add the annotations allowing the AnnotationReader to validate annotations as of Common 2.2 (see the ORM or @schmittjoh's bundles for instance)

@lsmith77 Owner

fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@stof stof commented on the diff
DependencyInjection/FOSRestExtension.php
@@ -128,6 +129,10 @@ public function load(array $configs, ContainerBuilder $container)
} else {
$container->setParameter($this->getAlias().'.mime_types', array());
}
+
+ if (!empty($config['query_fetcher_listener'])) {
@stof Owner
stof added a note

you could use if ($config['query_fetcher_listener']) { as you will always have a boolean (the node is a booleanNode and it has a default value)

@lsmith77 Owner

this would mean i need to write a lot more code into the tests .. its fine like it is.

@stof Owner
stof added a note

well, my comment is not really accurate anymore as it is not a boolean anymore (because of force)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Request/QueryFetcher.php
((67 lines not shown))
+ throw new \InvalidArgumentException('Controller needs to be set as a class instance (closures/functions are not supported)');
+ }
+
+ $this->params = $this->queryParamReader->read(new \ReflectionClass($this->controller[0]), $this->controller[1]);
+ }
+
+ /**
+ * Get a validated query parameter.
+ *
+ * @param string $name Name of the query parameter
+ *
+ * @return mixed Value of the parameter.
+ */
+ public function getParameter($name)
+ {
+ if (!isset($this->params)) {
@stof Owner
stof added a note

I would use if (null === $this->params) {

@lsmith77 Owner

fixed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Request/QueryParamReader.php
((7 lines not shown))
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace FOS\RestBundle\Request;
+
+use Doctrine\Common\Annotations\Reader;
+use FOS\RestBundle\Controller\Annotations\QueryParam;
+
+/**
+ * Class loading @QueryParameter annotations from methods.
+ *
+ * @author Alexander <iam.asm89@gmail.com>
+ */
+class QueryParamReader
@stof Owner
stof added a note

I think you should have an interface for the QueryParamReader, allowing people to have another implementation reading from another source than annotations

@lsmith77 Owner

fixed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Request/QueryFetcher.php
((45 lines not shown))
+ *
+ * @param QueryParamReader $queryParamReader Query param reader
+ * @param Request $request Active request
+ */
+ public function __construct(QueryParamReader $queryParamReader, Request $request)
+ {
+ $this->queryParamReader = $queryParamReader;
+ $this->request = $request;
+ }
+
+ public function setController($controller)
+ {
+ $this->controller = $controller;
+ }
+
+ private function initParams()
@stof Owner
stof added a note

private methods should be declared after public ones

@lsmith77 Owner

fixed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@Tobion
Collaborator

I agree that query param requirements shouldn't prevent route matching. As far as I see the requirements for params have two use cases:

  • I like that the default value is returned if a requirement for a param doesn't match.
  • I guess specifiying the requirement for a param is also used for API documentation.

But I don't see what this PR has to do with URI templates, that was also discussed here. And I thought we might plan to support query params in the routing component directly. There I would implement this quite differently (without using QueryFetcher etc).

@lsmith77
Owner

See my comment above. Imho the handling of what is supposed to happen in case of a mismatch of a query parameter will usually require specific logic that cannot be sensible defined via configuration in the routing. As such imho it should be handled in the controller. All this PR does is make it easier to do so.

As for uri-templates, the point again wasn't full uri-template support, but simply query parameter support. However as I just noted I don't think that the routing configuration is the right place to handle query parameters entirely. F.e. adding a requirement in the routing would cause the route to not match. Setting defaults makes a bit more sense via the routing layer, but without being able to handle setting a default on a mismatch etc its again not flexible enough.

@Tobion
Collaborator

I think this is all easiliy possible in the routing, even without breaking BC. I image a new option, besides pattern, for specifying query params: let's call it query. Example:

blog_show:
    pattern:   /blog/{slug}
    query: 
        param1: {param1}
        param2: {param2}
    defaults:  { param1: 1 }
    requirements:
        param1:  \d+

As you can see, we can simply use the existing default and requirements sections and introduce a new option for queries (similar to what is planned to be merged for host requirements). But a requirement for a param in the query section will of course not be used for matching. The query params will be passed as arguements to the controller action (only the ones specified, not all). And for validating them and returning the default on mismatch can be achieved by introducing a new method in Request (in addition to the standard $request->get()). This will allow a developer to implement any specific logic in the controller he wants.

That way, it will also make the implementation independent of where the parameter comes from (path, hostname or query). So one can simply change the route without needing to adjust any code. For example we decide to change the location of param2 in the above route and we are done:

blog_show:
    pattern:   /blog/{slug}/{param2}
    query: 
        param1: {param1}
    defaults:  { param1: 1 }
    requirements:
        param1:  \d+
@lsmith77
Owner

yeah .. but isn't that super confusing that the behavior of requirements changes based on where it matched? seems like a huge security risk.

@Tobion
Collaborator

I don't think so. Moving a param from query to path is even more restrictive. And moving from path to query will validate the requirement and pass the default to the action if it doesnt match. And if you have a non-matching requirement but not configured a default neither in the route nor in the action (fooAction($param1)) it will create a 404 (or a specific exception). You can of course specify a default in the action to circumvent the 404 and do your custom validation and logic in the controller.

@lsmith77
Owner

its still a change in behavior that isn't very transparent to the user

@asm89
Owner

The code hasn't changed a lot from the original PoC I send. I think that if this would get merged it would be supporting the original idea? #178 In short the idea was to support the developer with checking and documenting query parameters used in api controller actions.

@Tobion
Collaborator

I don't see any other reasonable way to implement query params in the routing component. And it's basically the same configuration style as with the upcoming hostname-pattern option. So quite straightforward. How would you like to see it implemented in core?

@lsmith77
Owner

well imho setting a requirement on a query param should cause an exception. which is why i was considering merging this PR. however maybe we could have a "validation" section that behaves like what we described above for query parameters.

@Tobion
Collaborator

I think having both a requirements (for path params and hostname params) and a validation (for query params) option makes it even more confusing. People would think: Are requirements not validated? Do I need to place a param in both sections to be sure?
Btw, in your current implementation your option is called requirement, too. So also the same option name with different meaning (in @Route and in @QueryParam

@lsmith77
Owner

but its not the same configuration. the difference is that people now know how requirements in the route work. we can't just have a different behavior depending on if its a path or query parameter. the only thing we could do is disallow using requirements for query parameters. but then we would still need a way to define such validation rules. now handling of default values could indeed be done via the routing.

however right now the routing is focused on the matching process only. while i was the one suggesting to add query support to the routing layer initially .. i am not so sure it makes sense now after all.

@Tobion
Collaborator

My concern is, that the main point of configuring routes is that it hides the implementation. So currently you can change the order of path params or change a prefix etc. without touching code for url matching and url generation. It will still work.
Same with the hostname PR: You will be able to change the locale from a hostname {_locale}.example.org to the path without problems.
But this implementation for query params can only be used for query params. You cannot even change the param name without modifying all code. E.g. you introcude a typo for a query name or simply want to change it, like ?query=value to ?q=value. It's not possible without rewriting code.

With my idea you can simply reconfigure it like

query: 
    q: {query}

and you're done. No need to touch the templates etc.

Btw query: [param1, param2, param3] should be a shorthand for query: { param1: {param1}, param2: {param2}, param3: {param3} }. And one could do stuff like query: { price: {amout}{currency} }.

@lsmith77
Owner

well sure that could be nice in order to deal with legacy or 3rd party controllers. but i don't really think that the ability to alias query param names is something we really need in core.

what i do think we need to provide for either in core or in this bundle is the ability to:

  • define the query parameters supported by a controller action
  • define the default values for this parameters if they are not supplied
  • define validation rules for these parameters (note that here in most causes falling back to a default is the right thing, but in other cases i might also want some assistance in determine what doesn't match so that i can return a custom error message).
@breerly

this is wicked sexy, btw.

@grEvenX

This rocks @lsmith77 , what's does the fortuneteller say about when this stuff is ready to be merged into master? And what's left before that can happen?

@lsmith77
Owner

as the example shows .. it basically works ..
now .. the main question is if we agree its the right approach ..
or should this rather be done inside the routing config?

@grEvenX

From my personal view, I think this approach is just fine. But I'm not a Symfony advocate, thus my points might/should not count for much in this case. But as you said, this works, and isn't that good enough for a first version.
Couldn't the possibility to configure it through routing config be added as an option at a later stage if there is actually need for it?

@lsmith77
Owner

sure it could be added later .. or replaced with .. would just like to avoid needless confusion

@grEvenX

Yes, but isn't that the same as for Route configuration. The developer has a choice, either through routing config or annotations?

@grEvenX

Btw, trying to test this out, but I get "<CLASS> requires that you provide a value for the "$queryFetcher" argument (because there is no default value or because there is a non optional argument after this one)." when I use first parameter with QueryFetcher. I've also added the QueryParam annotation to the method. It's based on the same as your example but using annotations to build the Route etc.
What is needed in order for the QueryFetcher to be injected when the method is called?

@grEvenX

Thanks for pointing that out, works now :) now off to do some real testing

@coderbyheart

I also don't think the query parameter configuration should be part of the routing configuration as this tends to change quite often and would add a lot of noise to the routing config and add more ways to introduce errors to that file.

I'd favour a more expressive solution than having one QueryParamReader which behaves essentially like the Request.

If one decides to mark up his desired query params he should get them nicely laid out for him:

/**
 * Get the list of articles
 *
 * @param int $page
 * @QueryParam(name="page", requirements="\d+", default="1", description="Page of the overview.")
 */
public function getArticlesAction($page)
{

This perfectly aligns to the request parameter configuration where you also get to use the named parameter from the routing configuration.

@lsmith77
Owner

We can easily create a request listener that does that or rather extend the current one to optionally do that. The reason I say optionally is that with what you describe all parameters would always have to be parsed and validated even if they are not used, but in most cases most of the configured query params will likely be used anyway.

@coderbyheart

Of course one could image getting some query params only conditinally:

if ($search = $queryFetcher->getParameter('search')) {
    $term = $queryFetcher->getParameter('term');
}

but I personally would just fetch all the queryparams in one block anyway and deal with them conditionally later.

$search = $queryFetcher->getParameter('search');
$term = $queryFetcher->getParameter('term');
…
if ($search) { … }
@grEvenX

I agree on the expressability part of injecting the query params.
If one defines a route with parameters and also uses queryparam, how will it affect the method signature and which (if any) would take precedence?

E.g:

/*
 * @Route("/{id}", requirements={"id" = "\d+"})
 * @QueryParam(name="id",requirements={"id" = "\d+"})
 */
public function getCustomerAction($id) 
@stof
Owner

@grEvenX The bundle does not change the request attributes, and so will not change the params available for the method arguments.

@lsmith77
Owner

@stof but we could ..

@coderbyheart

@grEvenX I think a conflict should raise an exception.

@stof
Owner

it would duplicate the param in 2 parameter bags (query and attributes). I don't think it is a good idea

@asm89
Owner

Honestly I think it doesn't make sense to inject the query parameters in your method signature. That would make your code even less portable. My original idea was that the query param thing could help you with checking very basic requirements on the query parameters (like &page should be \d+) and return a default value (1 for page) or null otherwise. The upside of this would be less checking of stuff like that "by hand", but also having controller methods annotated with the appropriate query parameters which is very nice for api documentation etc.

@coderbyheart

That would make your code even less portable.

Can you elaborate?

Do you mean, by hard coding the query params in the method signature one limits possibility of extending the method in a subclass with a different implementation which would require other params?

@lsmith77
Owner

ok .. i have implemented the option of forcing the query params to be set as attributes. on conflict it throws an exception. however we also need to improve the route generation to skip any configured query parameter.

@lsmith77
Owner

btw .. just FYI .. you can always fork this branch and submit PRs back to this branch if you have ideas for improvements. for example it would be cool of someone could fix the above noted issue with using query params in the method signature causing them to be put into the route.

@lsmith77
Owner

nevermind .. already took care of it ..
anything else people think should be done?

@lsmith77
Owner

main thing missing now are docs ..

@stof
Owner

@lsmith77 As I already said a while ago, I think you should add an interface for the QueryParamReader and use it in the typehints, to allow people to replace the implementation if they want.

@lsmith77
Owner

QueryParamReader or QueryFetcher? Anyway, I will leave that to who ever wants to make the first alternative implementation :)

@stof
Owner

probably both :)
But I was talking about the reader first, thus allowing people to write an implementation reading from elsewhere than annotations if they don't like annotations

DependencyInjection/Configuration.php
@@ -39,6 +39,7 @@ public function getConfigTreeBuilder()
$rootNode
->children()
+ ->scalarNode('query_fetcher_listener')->defaultValue(false)->end()
@stof Owner
stof added a note

shouldn't it be a booleanNode ? And you could use defaultFalse()

@stof Owner
stof added a note

ok, it is not a boolean. But you should probably limit the possible values

@lsmith77 Owner

whats the syntax for that?

@lsmith77 Owner

fixed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
DependencyInjection/Configuration.php
@@ -39,6 +41,12 @@ public function getConfigTreeBuilder()
$rootNode
->children()
+ ->scalarNode('query_fetcher_listener')->defaultFalse()
+ ->validate()
+ ->ifNotInArray($this->forceOptionValues)
+ ->thenInvalid('The query_fetcher_listener option does not support %s. Please choose one of '.json_encode($this->forceOptionValues))
@stof Owner
stof added a note

missing one indentation level

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
DependencyInjection/Configuration.php
@@ -111,7 +119,12 @@ private function addViewSection(ArrayNodeDefinition $rootNode)
->defaultValue(array('html' => true))
->prototype('boolean')->end()
->end()
- ->scalarNode('view_response_listener')->defaultValue('force')->end()
+ ->scalarNode('view_response_listener')->defaultValue('force')
+ ->validate()
+ ->ifNotInArray($this->forceOptionValues)
+ ->thenInvalid('The view_response_listener option does not support %s. Please choose one of '.json_encode($this->forceOptionValues))
@stof Owner
stof added a note

same here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@lsmith77 lsmith77 merged commit 0aa7534 into master
@schmittjoh
Owner

About silently setting the default value on validation failure, would it not make more sense to redirect to the URL with the default value? Also in terms of SEO, and duplicate content, this seems more sensible, no?

@lsmith77
Owner

i thought about that too .. we could provide such an option as part of the listener eventually. it should of course only be down for proper GET requests. however i don't think that query parameters are that important for SEO, but it is relevant for caches i guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Feb 1, 2012
  1. @asm89
  2. @asm89

    Add tests

    asm89 authored
Commits on Feb 7, 2012
  1. @asm89
  2. @lsmith77
  3. @lsmith77
  4. @lsmith77
  5. @lsmith77
  6. @lsmith77

    fix tests

    lsmith77 authored
  7. @lsmith77

    cosmetic tweaks

    lsmith77 authored
Commits on Feb 8, 2012
  1. @lsmith77
  2. @lsmith77
  3. @lsmith77

    use a controller listener

    lsmith77 authored
  4. @lsmith77

    tweaked error handling

    lsmith77 authored
  5. @lsmith77

    Merge pull request #186 from FriendsOfSymfony/query_param-proof-of-co…

    lsmith77 authored
    …ncept2
    
    Query param proof of concept with a controller listener
  6. @lsmith77

    tweaked error handling

    lsmith77 authored
Commits on Feb 22, 2012
  1. @lsmith77
Commits on Mar 2, 2012
  1. @lsmith77
  2. @lsmith77
Commits on Mar 24, 2012
  1. @lsmith77
  2. @lsmith77

    updated config reference

    lsmith77 authored
Commits on Mar 29, 2012
  1. @lsmith77
Commits on Mar 30, 2012
  1. @lsmith77
Commits on Apr 3, 2012
  1. @lsmith77
  2. @lsmith77
Commits on Apr 10, 2012
  1. @lsmith77
Commits on Apr 11, 2012
  1. @lsmith77
Commits on Apr 12, 2012
  1. @lsmith77
Commits on Apr 13, 2012
  1. @lsmith77
  2. @lsmith77
Commits on Apr 15, 2012
  1. @lsmith77
Commits on Apr 18, 2012
  1. @lsmith77
Commits on Apr 19, 2012
  1. @lsmith77
  2. @lsmith77
Commits on May 13, 2012
  1. @lsmith77
Commits on May 15, 2012
  1. @lsmith77
  2. @lsmith77
  3. @lsmith77
  4. @lsmith77

    cosmetics

    lsmith77 authored
  5. @lsmith77

    added documentation

    lsmith77 authored
  6. @lsmith77

    fix tests

    lsmith77 authored
Commits on May 16, 2012
  1. @lsmith77

    added strict mode

    lsmith77 authored
  2. @lsmith77

    some more docs

    lsmith77 authored
  3. @lsmith77

    validate parameters

    lsmith77 authored
  4. @lsmith77

    simplified code

    lsmith77 authored
  5. @lsmith77

    added interfaces

    lsmith77 authored
  6. @lsmith77

    validate configs

    lsmith77 authored
  7. @lsmith77
  8. @lsmith77

    cosmetics

    lsmith77 authored
  9. @lsmith77

    ws tweak

    lsmith77 authored
This page is out of date. Refresh to see the latest.
View
30 Controller/Annotations/QueryParam.php
@@ -0,0 +1,30 @@
+<?php
+
+/*
+ * This file is part of the FOSRestBundle package.
+ *
+ * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace FOS\RestBundle\Controller\Annotations;
+
+/**
+ * QueryParam annotation class.
+ *
+ * @Annotation
+ * @author Alexander <iam.asm89@gmail.com>
+ */
+class QueryParam
+{
+ /** @var string */
+ public $name;
+ /** @var string */
+ public $requirements;
+ /** @var string */
+ public $default;
+ /** @var string */
+ public $description;
+}
View
15 DependencyInjection/Configuration.php
@@ -27,6 +27,8 @@
*/
class Configuration implements ConfigurationInterface
{
+ private $forceOptionValues = array(false, true, 'force');
+
/**
* Generates the configuration tree.
*
@@ -39,6 +41,12 @@ public function getConfigTreeBuilder()
$rootNode
->children()
+ ->scalarNode('query_fetcher_listener')->defaultFalse()
+ ->validate()
+ ->ifNotInArray($this->forceOptionValues)
+ ->thenInvalid('The query_fetcher_listener option does not support %s. Please choose one of '.json_encode($this->forceOptionValues))
+ ->end()
+ ->end()
->arrayNode('routing_loader')
->addDefaultsIfNotSet()
->children()
@@ -111,7 +119,12 @@ private function addViewSection(ArrayNodeDefinition $rootNode)
->defaultValue(array('html' => true))
->prototype('boolean')->end()
->end()
- ->scalarNode('view_response_listener')->defaultValue('force')->end()
+ ->scalarNode('view_response_listener')->defaultValue('force')
+ ->validate()
+ ->ifNotInArray($this->forceOptionValues)
+ ->thenInvalid('The view_response_listener option does not support %s. Please choose one of '.json_encode($this->forceOptionValues))
+ ->end()
+ ->end()
->scalarNode('failed_validation')->defaultValue(Codes::HTTP_BAD_REQUEST)->end()
->end()
->end()
View
9 DependencyInjection/FOSRestExtension.php
@@ -39,6 +39,7 @@ public function load(array $configs, ContainerBuilder $container)
$loader->load('view.xml');
$loader->load('routing.xml');
$loader->load('util.xml');
+ $loader->load('request.xml');
if (version_compare(FOSRestBundle::getSymfonyVersion(Kernel::VERSION), '2.1.0', '<')) {
$container->setParameter('fos_rest.routing.loader.controller.class', $container->getParameter('fos_rest.routing.loader_2_0.controller.class'));
@@ -128,6 +129,14 @@ public function load(array $configs, ContainerBuilder $container)
} else {
$container->setParameter($this->getAlias().'.mime_types', array());
}
+
+ if (!empty($config['query_fetcher_listener'])) {
@stof Owner
stof added a note

you could use if ($config['query_fetcher_listener']) { as you will always have a boolean (the node is a booleanNode and it has a default value)

@lsmith77 Owner

this would mean i need to write a lot more code into the tests .. its fine like it is.

@stof Owner
stof added a note

well, my comment is not really accurate anymore as it is not a boolean anymore (because of force)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ $loader->load('query_fetcher_listener.xml');
+
+ if ('force' === $config['query_fetcher_listener']) {
+ $container->setParameter($this->getAlias().'.query_fetch_listener.set_params_as_attributes', true);
+ }
+ }
}
/**
View
71 EventListener/QueryFetcherListener.php
@@ -0,0 +1,71 @@
+<?php
+
+/*
+ * This file is part of the FOSRestBundle package.
+ *
+ * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace FOS\RestBundle\EventListener;
+
+use Symfony\Component\HttpKernel\Event\FilterControllerEvent,
+ Symfony\Component\HttpKernel\HttpKernelInterface,
+ Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * This listener handles various setup tasks related to the query fetcher
+ *
+ * Setting the controller callable on the query fetcher
+ * Setting the query fetcher as a request attribute
+ *
+ * @author Lukas Kahwe Smith <smith@pooteeweet.org>
+ */
+class QueryFetcherListener
+{
+ /**
+ * @var ContainerInterface
+ */
+ private $container;
+
+ private $setParamsAsAttributes;
+
+ /**
+ * Constructor.
+ *
+ * @param ContainerInterface $container container
+ */
+ public function __construct(ContainerInterface $container, $setParamsAsAttributes = false)
+ {
+ $this->container = $container;
+ $this->setParamsAsAttributes = $setParamsAsAttributes;
+ }
+
+ /**
+ * Core controller handler
+ *
+ * @param FilterControllerEvent $event The event
+ */
+ public function onKernelController(FilterControllerEvent $event)
+ {
+ $request = $event->getRequest();
+ $queryFetcher = $this->container->get('fos_rest.request.query_fetcher');
+
+ $queryFetcher->setController($event->getController());
+ $request->attributes->set('queryFetcher', $queryFetcher);
+
+ if ($this->setParamsAsAttributes) {
+ $params = $queryFetcher->all();
+ foreach ($params as $name => $param) {
+ if ($request->attributes->has($name)) {
+ $msg = sprintf("QueryFetcher parameter conflicts with a path parameter '$name' for route '%s'", $request->attributes->get('_route'));
+ throw new \InvalidArgumentException($msg);
+ }
+
+ $request->attributes->set($name, $param);
+ }
+ }
+ }
+}
View
135 Request/QueryFetcher.php
@@ -0,0 +1,135 @@
+<?php
+
+/*
+ * This file is part of the FOSRestBundle package.
+ *
+ * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace FOS\RestBundle\Request;
+
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Helper to validate query parameters from the active request.
+ *
+ * @author Alexander <iam.asm89@gmail.com>
+ * @author Lukas Kahwe Smith <smith@pooteeweet.org>
+ */
+class QueryFetcher implements QueryFetcherInterface
+{
+ /**
+ * @var QueryParamReader
+ */
+ private $queryParamReader;
+
+ /**
+ * @var Request
+ */
+ private $request;
+
+ /**
+ * @var array
+ */
+ private $params;
+
+ /**
+ * @var callable
+ */
+ private $controller;
+
+ /**
+ * Initializes fetcher.
+ *
+ * @param QueryParamReader $queryParamReader Query param reader
+ * @param Request $request Active request
+ */
+ public function __construct(QueryParamReader $queryParamReader, Request $request)
+ {
+ $this->queryParamReader = $queryParamReader;
+ $this->request = $request;
+ }
+
+ /**
+ * @abstract
+ * @param callable $controller
+ *
+ * @return void
+ */
+ public function setController($controller)
+ {
+ $this->controller = $controller;
+ }
+
+ /**
+ * Get a validated query parameter.
+ *
+ * @param string $name Name of the query parameter
+ * @param Boolean $strict If a requirement mismatch should cause an exception
+ *
+ * @return mixed Value of the parameter.
+ */
+ public function get($name, $strict = false)
+ {
+ if (null === $this->params) {
+ $this->initParams();
+ }
+
+ if (!array_key_exists($name, $this->params)) {
+ throw new \InvalidArgumentException(sprintf("No @QueryParam configuration for parameter '%s'.", $name));
+ }
+
+ $config = $this->params[$name];
+ $default = $config->default;
+ $param = $this->request->query->get($name, $default);
+
+ // Set default if the requirements do not match
+ if ($param !== $default && !preg_match('#^'.$config->requirements.'$#xs', $param)) {
+ if ($strict) {
+ throw new \RuntimeException("Query parameter value '$param', does not match requirements '{$config->requirements}'");
+ }
+
+ $param = $default;
+ }
+
+ return $param;
+ }
+
+ /**
+ * Get all validated query parameter.
+ *
+ * @param Boolean $strict If a requirement mismatch should cause an exception
+ *
+ * @return array Values of all the parameters.
+ */
+ public function all($strict = false)
+ {
+ $params = array();
+ foreach ($this->params as $name => $config) {
+ $params[$name] = $this->get($name, $strict);
+ }
+
+ return $params;
+ }
+
+ /**
+ * Initialize the parameters
+ *
+ * @throws \InvalidArgumentException
+ */
+ private function initParams()
+ {
+ if (empty($this->controller)) {
+ throw new \InvalidArgumentException('Controller and method needs to be set via setController');
+ }
+
+ if (!is_array($this->controller) || empty($this->controller[0]) || !is_object($this->controller[0])) {
+ throw new \InvalidArgumentException('Controller needs to be set as a class instance (closures/functions are not supported)');
+ }
+
+ $this->params = $this->queryParamReader->read(new \ReflectionClass($this->controller[0]), $this->controller[1]);
+ }
+}
View
50 Request/QueryFetcherInterface.php
@@ -0,0 +1,50 @@
+<?php
+
+/*
+ * This file is part of the FOSRestBundle package.
+ *
+ * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace FOS\RestBundle\Request;
+
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Helper interface to validate query parameters from the active request.
+ *
+ * @author Alexander <iam.asm89@gmail.com>
+ * @author Lukas Kahwe Smith <smith@pooteeweet.org>
+ */
+interface QueryFetcherInterface
+{
+ /**
+ * @abstract
+ * @param callable $controller
+ *
+ * @return void
+ */
+ function setController($controller);
+
+ /**
+ * Get a validated query parameter.
+ *
+ * @param string $name Name of the query parameter
+ * @param Boolean $strict If a requirement mismatch should cause an exception
+ *
+ * @return mixed Value of the parameter.
+ */
+ function get($name, $strict = false);
+
+ /**
+ * Get all validated query parameter.
+ *
+ * @param Boolean $strict If a requirement mismatch should cause an exception
+ *
+ * @return array Values of all the parameters.
+ */
+ function all($strict = false);
+}
View
74 Request/QueryParamReader.php
@@ -0,0 +1,74 @@
+<?php
+
+/*
+ * This file is part of the FOSRestBundle package.
+ *
+ * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace FOS\RestBundle\Request;
+
+use Doctrine\Common\Annotations\Reader;
+use FOS\RestBundle\Controller\Annotations\QueryParam;
+
+/**
+ * Class loading @QueryParameter annotations from methods.
+ *
+ * @author Alexander <iam.asm89@gmail.com>
+ * @author Lukas Kahwe Smith <smith@pooteeweet.org>
+ */
+class QueryParamReader implements QueryParamReaderInterface
+{
+ private $annotationReader;
+
+ /**
+ * Initializes controller reader.
+ *
+ * @param Reader $annotationReader annotation reader
+ */
+ public function __construct(Reader $annotationReader)
+ {
+ $this->annotationReader = $annotationReader;
+ }
+
+ /**
+ * Read annotations for a given method.
+ *
+ * @param \ReflectionClass $reflection Reflection class
+ * @param string $method Method name
+ *
+ * @return array QueryParam annotation objects of the method. Indexed by parameter name.
+ */
+ public function read(\ReflectionClass $reflection, $method)
+ {
+ if (!$reflection->hasMethod($method)) {
+ throw new \InvalidArgumentException(sprintf("Class '%s' has no method '%s' method.", $reflection->getName(), $method));
+ }
+
+ return $this->getParamsFromMethod($reflection->getMethod($method));
+ }
+
+ /**
+ * Read annotations for a given method.
+ *
+ * @param \ReflectionMethod $method Reflection method
+ *
+ * @return array QueryParam annotation objects of the method. Indexed by parameter name.
+ */
+ public function getParamsFromMethod(\ReflectionMethod $method)
+ {
+ $annotations = $this->annotationReader->getMethodAnnotations($method);
+
+ $params = array();
+ foreach ($annotations as $annotation) {
+ if ($annotation instanceof QueryParam) {
+ $params[$annotation->name] = $annotation;
+ }
+ }
+
+ return $params;
+ }
+}
View
40 Request/QueryParamReaderInterface.php
@@ -0,0 +1,40 @@
+<?php
+
+/*
+ * This file is part of the FOSRestBundle package.
+ *
+ * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace FOS\RestBundle\Request;
+
+/**
+ * interface for loading query parameters for a method
+ *
+ * @author Alexander <iam.asm89@gmail.com>
+ * @author Lukas Kahwe Smith <smith@pooteeweet.org>
+ */
+interface QueryParamReaderInterface
+{
+ /**
+ * Read annotations for a given method.
+ *
+ * @param \ReflectionClass $reflection Reflection class
+ * @param string $method Method name
+ *
+ * @return array QueryParam annotation objects of the method. Indexed by parameter name.
+ */
+ function read(\ReflectionClass $reflection, $method);
+
+ /**
+ * Read annotations for a given method.
+ *
+ * @param \ReflectionMethod $method Reflection method
+ *
+ * @return array QueryParam annotation objects of the method. Indexed by parameter name.
+ */
+ function getParamsFromMethod(\ReflectionMethod $method);
+}
View
23 Resources/config/query_fetcher_listener.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" ?>
+
+<container xmlns="http://symfony.com/schema/dic/services"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
+
+ <parameters>
+
+ <parameter key="fos_rest.query_fetch_listener.class">FOS\RestBundle\EventListener\QueryFetcherListener</parameter>
+ <parameter key="fos_rest.query_fetch_listener.set_params_as_attributes">false</parameter>
+
+ </parameters>
+
+ <services>
+
+ <service id="fos_rest.query_fetch_listener" class="%fos_rest.query_fetch_listener.class%">
+ <tag name="kernel.event_listener" event="kernel.controller" method="onKernelController"/>
+ <argument type="service" id="service_container"/>
+ <argument>%fos_rest.query_fetch_listener.set_params_as_attributes%</argument>
+ </service>
+
+ </services>
+</container>
View
27 Resources/config/request.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" ?>
+
+<container xmlns="http://symfony.com/schema/dic/services"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
+
+ <parameters>
+
+ <parameter key="fos_rest.request.query_fetcher.class">FOS\RestBundle\Request\QueryFetcher</parameter>
+ <parameter key="fos_rest.request.query_fetcher.reader.query_param.class">FOS\RestBundle\Request\QueryParamReader</parameter>
+
+ </parameters>
+
+ <services>
+
+ <service id="fos_rest.request.query_fetcher" class="%fos_rest.request.query_fetcher.class%" scope="request">
+ <argument type="service" id="fos_rest.request.query_fetcher.reader.query_param"/>
+ <argument type="service" id="request"/>
+ </service>
+
+ <service id="fos_rest.request.query_fetcher.reader.query_param" class="%fos_rest.request.query_fetcher.reader.query_param.class%">
+ <argument type="service" id="annotation_reader"/>
+ </service>
+
+ </services>
+
+</container>
View
1  Resources/config/routing.xml
@@ -47,6 +47,7 @@
<service id="fos_rest.routing.loader.reader.action" class="%fos_rest.routing.loader.reader.action.class%">
<argument type="service" id="annotation_reader" />
+ <argument type="service" id="fos_rest.request.query_fetcher.reader.query_param" />
</service>
</services>
View
61 Resources/doc/3-listener-support.md
@@ -214,3 +214,64 @@ fos_rest:
## That was it!
[Return to the index](index.md) or continue reading about [ExceptionController support](4-exception-controller-support.md).
+
+### Query fetcher listener
+
+The query fetcher listener simply sets the QueryFetcher instance as a request attribute
+configured for the matched controller so that the user does not need to do this manually.
+
+```yaml
+# app/config/config.yml
+fos_rest:
+ query_fetcher_listener: true
+```
+
+```php
+class FooController extends Controller
+{
+ /**
+ * Will look for a page query parameters, ie. ?page=XX
+ * If not passed it will be automatically be set to the default of "1"
+ * If passed but doesn't match the requirement "\d+" it will be also be set to the default of "1"
+ * Note that if the value matches the default then no validation is run.
+ * So make sure the default value really matches your expectations.
+ * @QueryParam(name="page", requirements="\d+", default="1", description="Page of the overview.")
+ *
+ * @param QueryFetcher $queryFetcher
+ */
+ public function getArticlesAction(QueryFetcher $queryFetcher)
+ {
+ $page = $queryFetcher->get('page');
+ $articles = array('bim', 'bam', 'bingo');
+
+ return array('articles' => $articles, 'page' => $page);
+ }
+```
+
+Note: There is also ``$queryFetcher->all()`` to fetch all configured query parameters at once. And also
+both ``$queryFetcher->get()`` and ``$queryFetcher->all()`` support and optional ``$strict`` parameter
+to throw a ``\RuntimeException`` on a validation error.
+
+Optionally the listener can also already set all configured query parameters as request attributes
+
+```yaml
+# app/config/config.yml
+fos_rest:
+ query_fetcher_listener: force
+```
+
+```php
+class FooController extends Controller
+{
+ /**
+ * @QueryParam(name="page", requirements="\d+", default="1", description="Page of the overview.")
+ *
+ * @param string $page
+ */
+ public function getArticlesAction($page)
+ {
+ $articles = array('bim', 'bam', 'bingo');
+
+ return array('articles' => $articles, 'page' => $page);
+ }
+```
View
61 Routing/Loader/Reader/RestActionReader.php
@@ -12,9 +12,12 @@
namespace FOS\RestBundle\Routing\Loader\Reader;
use Doctrine\Common\Annotations\Reader;
-use FOS\RestBundle\Util\Pluralization;
+
use Symfony\Component\Routing\Route;
+
+use FOS\RestBundle\Util\Pluralization;
use FOS\RestBundle\Routing\RestRouteCollection;
+use FOS\RestBundle\Request\QueryParamReader;
/**
* REST controller actions reader.
@@ -24,6 +27,7 @@
class RestActionReader
{
private $annotationReader;
+ private $queryParamReader;
private $routePrefix;
private $namePrefix;
@@ -37,9 +41,10 @@ class RestActionReader
*
* @param Reader $annotationReader annotation reader
*/
- public function __construct(Reader $annotationReader)
+ public function __construct(Reader $annotationReader, QueryParamReader $queryParamReader)
{
$this->annotationReader = $annotationReader;
+ $this->queryParamReader = $queryParamReader;
}
/**
@@ -128,7 +133,8 @@ public function read(RestRouteCollection $collection, \ReflectionMethod $method)
}
// if we can't get http-method and resources from method name - skip
- if (!($httpMethodAndResources = $this->getHttpMethodAndResourcesFromMethod($method))) {
+ $httpMethodAndResources = $this->getHttpMethodAndResourcesFromMethod($method);
+ if (!$httpMethodAndResources) {
return;
}
@@ -168,7 +174,8 @@ public function read(RestRouteCollection $collection, \ReflectionMethod $method)
$requirements = array('_method' => strtoupper($httpMethod));
$options = array();
- if ($annotation = $this->readRouteAnnotation($method)) {
+ $annotation = $this->readRouteAnnotation($method);
+ if ($annotation) {
$annoRequirements = $annotation->getRequirements();
if (!isset($annoRequirements['_method'])) {
@@ -200,6 +207,7 @@ private function isMethodReadable(\ReflectionMethod $method)
if ('_' === substr($method->getName(), 0, 1)) {
return false;
}
+
// if method has NoRoute annotation - skip
if ($this->readMethodAnnotation($method, 'NoRoute')) {
return false;
@@ -213,13 +221,13 @@ private function isMethodReadable(\ReflectionMethod $method)
*
* @param \ReflectionMethod $method
*
- * @return array
+ * @return Boolean|array
*/
private function getHttpMethodAndResourcesFromMethod(\ReflectionMethod $method)
{
// if method doesn't match regex - skip
if (!preg_match('/([a-z][_a-z0-9]+)(.*)Action/', $method->getName(), $matches)) {
- return;
+ return false;
}
$httpMethod = strtolower($matches[1]);
@@ -239,13 +247,29 @@ private function getHttpMethodAndResourcesFromMethod(\ReflectionMethod $method)
*/
private function getMethodArguments(\ReflectionMethod $method)
{
- // ignore arguments that are or extend from Symfony\Component\HttpFoundation\Request
+ // ignore all query params
+ $params = $this->queryParamReader->getParamsFromMethod($method);
+
+ // ignore type hinted arguments that are or extend from:
+ // * Symfony\Component\HttpFoundation\Request
+ // * FOS\RestBundle\Request\QueryFetcher
+ $ignoreClasses = array(
+ 'Symfony\Component\HttpFoundation\Request',
+ 'FOS\RestBundle\Request\QueryFetcherInterface',
+ );
+
$arguments = array();
foreach ($method->getParameters() as $argument) {
- if ($argumentClass = $argument->getClass()) {
- if ($argumentClass->getName() === 'Symfony\Component\HttpFoundation\Request'
- || $argumentClass->isSubclassOf('Symfony\Component\HttpFoundation\Request')) {
- continue;
+ if (isset($params[$argument->getName()])) {
+ continue;
+ }
+
+ $argumentClass = $argument->getClass();
+ if ($argumentClass) {
+ foreach ($ignoreClasses as $class) {
+ if ($argumentClass->getName() === $class || $argumentClass->isSubclassOf($class)) {
+ continue 2;
+ }
}
}
@@ -300,8 +324,7 @@ private function generateUrlParts(array $resources, array $arguments)
strtolower(Pluralization::pluralize($resource))
.'/{'.$arguments[$i]->getName().'}';
} else {
- $urlParts[] =
- '{'.$arguments[$i]->getName().'}';
+ $urlParts[] = '{'.$arguments[$i]->getName().'}';
}
} elseif (null !== $resource) {
$urlParts[] = strtolower($resource);
@@ -326,13 +349,15 @@ private function getCustomHttpMethod($httpMethod, array $resources, array $argum
// allow hypertext as the engine of application state
// through conventional GET actions
return 'get';
- } elseif (count($arguments) < count($resources)) {
+ }
+
+ if (count($arguments) < count($resources)) {
// resource collection
return 'get';
- } else {
- //custom object
- return 'patch';
}
+
+ //custom object
+ return 'patch';
}
/**
@@ -347,8 +372,6 @@ private function readRouteAnnotation(\ReflectionMethod $reflection)
foreach (array('Route','Get','Post','Put','Patch','Delete','Head') as $annotationName) {
if ($annotation = $this->readMethodAnnotation($reflection, $annotationName)) {
return $annotation;
-
- break;
}
}
}
View
175 Tests/Request/QueryFetcherTest.php
@@ -0,0 +1,175 @@
+<?php
+
+/*
+ * This file is part of the FOSRestBundle package.
+ *
+ * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace FOS\RestBundle\Tests\Request;
+
+use FOS\RestBundle\Controller\Annotations\NamePrefix;
+use FOS\RestBundle\Controller\Annotations\QueryParam;
+use FOS\RestBundle\Request\QueryParamReader;
+use FOS\RestBundle\Request\QueryFetcher;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * QueryParamReader test.
+ *
+ * @author Alexander <iam.asm89@gmail.com>
+ */
+class QueryFetcherTest extends \PHPUnit_Framework_TestCase
+{
+ private $controller;
+ private $queryParamReader;
+
+ /**
+ * Test setup.
+ */
+ public function setup()
+ {
+ $this->controller = array(new \stdClass(), 'indexAction');
+
+ $this->queryParamReader = $this->getMockBuilder('\FOS\RestBundle\Request\QueryParamReader')
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $annotations = array();
+ $annotations['foo'] = new QueryParam;
+ $annotations['foo']->name = 'foo';
+ $annotations['foo']->requirements = '\d+';
+ $annotations['foo']->default = '1';
+ $annotations['foo']->description = 'The foo';
+
+ $annotations['bar'] = new QueryParam;
+ $annotations['bar']->name = 'bar';
+ $annotations['bar']->requirements = '\d+';
+ $annotations['bar']->default = '1';
+ $annotations['bar']->description = 'The bar';
+
+ $this->queryParamReader
+ ->expects($this->any())
+ ->method('read')
+ ->will($this->returnValue($annotations));
+ }
+
+ /**
+ * Get a query fetcher.
+ *
+ * @param array $query Query parameters for the request.
+ * @param array $attributes Attributes for the request.
+ *
+ * @return QueryFetcher
+ */
+ public function getQueryFetcher($query = array(), $attributes = null)
+ {
+ $attributes = $attributes ?: array('_controller' => __CLASS__.'::stubAction');
+
+ $request = new Request($query, array(), $attributes);
+
+ return new QueryFetcher($this->queryParamReader, $request);
+ }
+
+ /**
+ * Test valid parameters.
+ *
+ * @param string $expected Expected query parameter value.
+ * @param string $expectedAll Expected query parameter values.
+ * @param array $query Query parameters for the request.
+ *
+ * @dataProvider validatesConfiguredQueryParamDataProvider
+ */
+ public function testValidatesConfiguredQueryParam($expected, $expectedAll, $query)
+ {
+ $queryFetcher = $this->getQueryFetcher($query);
+ $queryFetcher->setController($this->controller);
+ $this->assertEquals($expected, $queryFetcher->get('foo'));
+ $this->assertEquals($expectedAll, $queryFetcher->all());
+ }
+
+ /**
+ * Data provider for the valid parameters test.
+ *
+ * @return array Data
+ */
+ public static function validatesConfiguredQueryParamDataProvider()
+ {
+ return array(
+ array('1', array('foo' => '1', 'bar' => '1'), array('foo' => '1')),
+ array('42', array('foo' => '42', 'bar' => '1'), array('foo' => '42')),
+ array('1', array('foo' => '1', 'bar' => '1'), array('foo' => 'bar')),
+ );
+ }
+
+ /**
+ * Throw exception on invalid parameters.
+ */
+ public function testExceotionOnValidatesFailure()
+ {
+ $queryFetcher = $this->getQueryFetcher(array('foo' => 'bar'));
+ $queryFetcher->setController($this->controller);
+
+ try {
+ try {
+ $queryFetcher->get('foo', true);
+ $this->fail('Fetching get() in strict mode did not throw an exception');
+ } catch (\RuntimeException $e) {
+ try {
+ $queryFetcher->all(true);
+ $this->fail('Fetching all() in strict mode did not throw an exception');
+ } catch (\RuntimeException $e) {
+ return;
+ }
+ }
+ } catch (\Exception $e) {
+ $this->fail('Fetching in strict mode did not throw an \RuntimeException');
+ }
+ }
+
+ /**
+ * @expectedException LogicException
+ * @expectedExceptionMessage Controller and method needs to be set via setController
+ */
+ public function testExceptionOnRequestWithoutController()
+ {
+ $queryFetcher = new QueryFetcher($this->queryParamReader, new Request());
+ $queryFetcher->get('qux', '42');
+ }
+
+ /**
+ * @expectedException LogicException
+ * @expectedExceptionMessage Controller and method needs to be set via setController
+ */
+ public function testExceptionOnNoController()
+ {
+ $queryFetcher = $this->getQueryFetcher();
+ $queryFetcher->setController(array());
+ $queryFetcher->get('qux', '42');
+ }
+
+ /**
+ * @expectedException LogicException
+ * @expectedExceptionMessage Controller needs to be set as a class instance (closures/functions are not supported)
+ */
+ public function testExceptionOnNonController()
+ {
+ $queryFetcher = $this->getQueryFetcher();
+ $queryFetcher->setController(array('foo', 'bar'));
+ $queryFetcher->get('qux', '42');
+ }
+
+ /**
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage No @QueryParam configuration for parameter 'qux'.
+ */
+ public function testExceptionOnNonConfiguredQueryParameter()
+ {
+ $queryFetcher = $this->getQueryFetcher();
+ $queryFetcher->setController($this->controller);
+ $queryFetcher->get('qux', '42');
+ }
+}
View
80 Tests/Request/QueryParamReaderTest.php
@@ -0,0 +1,80 @@
+<?php
+
+/*
+ * This file is part of the FOSRestBundle package.
+ *
+ * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace FOS\RestBundle\Tests\Request;
+
+use FOS\RestBundle\Controller\Annotations\NamePrefix;
+use FOS\RestBundle\Controller\Annotations\QueryParam;
+use FOS\RestBundle\Request\QueryParamReader;
+
+/**
+ * QueryParamReader test.
+ *
+ * @author Alexander <iam.asm89@gmail.com>
+ */
+class QueryParamReaderTest extends \PHPUnit_Framework_TestCase
+{
+ private $queryParamReader;
+
+ /**
+ * Test setup.
+ */
+ public function setup()
+ {
+ $annotationReader = $this->getMock('\Doctrine\Common\Annotations\Reader');
+
+ $annotations = array();
+ $foo = new QueryParam;
+ $foo->name = 'foo';
+ $foo->requirements = '\d+';
+ $foo->description = 'The foo';
+ $annotations[] = $foo;
+
+ $bar = new QueryParam;
+ $bar->name = 'bar';
+ $bar->requirements = '\d+';
+ $bar->description = 'The bar';
+ $annotations[] = $bar;
+
+ $annotations[] = new NamePrefix(array());
+
+ $annotationReader
+ ->expects($this->any())
+ ->method('getMethodAnnotations')
+ ->will($this->returnValue($annotations));
+
+ $this->queryParamReader = new QueryParamReader($annotationReader);
+ }
+
+ /**
+ * Test that only QueryParam annotations are returned.
+ */
+ public function testReadsOnlyQueryParamAnnotations()
+ {
+ $annotations = $this->queryParamReader->read(new \ReflectionClass(__CLASS__), 'setup');
+
+ $this->assertCount(2, $annotations);
+
+ foreach ($annotations as $name => $annotation) {
+ $this->assertThat($annotation, $this->isInstanceOf('FOS\RestBundle\Controller\Annotations\QueryParam'));
+ $this->assertEquals($annotation->name, $name);
+ }
+ }
+
+ /**
+ * @expectedException InvalidArgumentException
+ * @expectedExceptionMessage Class 'FOS\RestBundle\Tests\Request\QueryParamReaderTest' has no method 'foo' method.
+ */
+ public function testExceptionOnNonExistingMethod()
+ {
+ $this->queryParamReader->read(new \ReflectionClass(__CLASS__), 'foo');
+ }
+}
View
4 Tests/Routing/Loader/LoaderTest.php
@@ -18,6 +18,7 @@
use FOS\RestBundle\Routing\Loader\RestRouteLoader;
use FOS\RestBundle\Routing\Loader\Reader\RestControllerReader;
use FOS\RestBundle\Routing\Loader\Reader\RestActionReader;
+use FOS\RestBundle\Request\QueryParamReader;
/**
* Base Loader testing class.
@@ -51,8 +52,9 @@ protected function getControllerLoader()
->getMock();
$annotationReader = $this->getAnnotationReader();
+ $queryParamReader = new QueryParamReader($annotationReader);
- $ar = new RestActionReader($annotationReader);
+ $ar = new RestActionReader($annotationReader, $queryParamReader);
$cr = new RestControllerReader($ar, $annotationReader);
return new RestRouteLoader($c, $p, $cr, 'html');
Something went wrong with that request. Please try again.