Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JMESPath Extractor - in progress under marklz #78

Closed
svanoort opened this issue Oct 2, 2015 · 24 comments
Closed

JMESPath Extractor - in progress under marklz #78

svanoort opened this issue Oct 2, 2015 · 24 comments

Comments

@svanoort
Copy link
Owner

svanoort commented Oct 2, 2015

What:
As a pyresttest user who needs to do more detailed JSON analysis, I would like to be able to use full jsonpath support in working with request bodies (extraction for variables and content analysis). This should be available if the library is installed and fail if not.

How:
I would like to add a jsonpath-rw extractor, set up to auto-register in the same way as the jsonschema validator.

It will also require adding extending the autoload extensions to iterate through a list of extensions. For now, that's sufficient but it might be desirable to try to autoload all of the 'ext' folder content.

This is an isolated task, easy enough for someone to execute as a PR.

@svanoort
Copy link
Owner Author

JMESPath may be a better option for some use cases, and supports returning the last element in an array, for example.

@marklz
Copy link

marklz commented Nov 23, 2015

Sam - is it something and a junior-midlevel python coder can help you with?

@svanoort
Copy link
Owner Author

@marklz Absolutely! It probably will not require much code to do the coupling, and all the aspects needed have documentation and examples (JSONschema extension, extensions.md for how to write an extension, and resttest.py loading of the jsonschema extension). Fairly easy to throw together tests for this as well.

@marklz
Copy link

marklz commented Nov 25, 2015

looked briefly at JMESPath ( https://github.com/jmespath/jmespath.py and the spec in the link above) - is it much more complex than this? I'm surely missing something...

import jmespath

class JMESPathExtractor(AbstractExtractor):
    """ Extractor that uses JMESPath syntax
        See http://jmespath.org/specification.html for details
    """
    extractor_type = 'jmespath'
    is_body_extractor = True

    def extract_internal(self, query=None, args=None, body=None, headers=None):
        try:
            body = json.loads(body)
            return jmespath.search(query, body)
        except ValueError:
            raise ValueError("Not legal JSON!")

    @classmethod
    def parse(cls, config):
        base = JMESPathExtractor()
        return cls.configure_base(config, base)
        return base

@svanoort
Copy link
Owner Author

@marklz Nope, not much more complex than that for the core! There's a couple more pieces to get it PR-ready, but not big ones. First we need some basic unit tests (and I'm not sure here if JMESPath for python returns JSON text fragments or Python objects, but if the first one, we need to call the json.loads function to parse them so the comparators work).

Second, is an autoload for the extension like we have for the jsonschema validator. So, less than a half-dozen lines of code, and there's already an example to work from.

Third: an entry in the advanced_guide with the registered name & description, a syntax example, and a link to the JMESPath page (for more advanced uses).

Finally: I do apologize for the delay! I've been sick with flu the last few days and only just got well enough to start looking at code again.

This looks like it would be a great addition to PyrestTest, and thank you!

@marklz
Copy link

marklz commented Dec 1, 2015

No worry about reply times - I have a full time job too :-)

  • Unit tests - I'm actually thinking to piggy back on JMESPath unit tests in https://github.com/jmespath/jmespath.py/tree/develop/tests/compliance; I can add code to test_validators.py that reads static texts as jmespath-compliance-1.json, jmespath-compliance-2.json etc, and then do queries and check for results - all based on JMESPath tests.Does it make sense or is it too little or too much?
  • autoload - I see two options here:
    Option 1 - add code to validators.py, and at the end of validators.py, next to similar statements add
register_extractor('jmespath', JMESPathExtractor.parse)

Option 2 - create a extractor_jmespath.py in ext/ folder, and at the end of that file add - not clear what. jsonschema has

EXTRACTORS = {'jmespath': JMESPathExtractor.parse}

and then in resttest.py add

try:
    import jmespath
    register_extensions('ext.extractor_jmespath')
except ImportError as ie:
    logging.debug(
        "Failed to load jmespath extractor, make sure the jmespath module is installed if you wish to use jmespath extractor.")

So, which way should I go?

Mark.

@svanoort svanoort changed the title JsonPath-rw Extractor JMESPath Extractor Dec 1, 2015
@svanoort
Copy link
Owner Author

svanoort commented Dec 2, 2015

Ah, those full-time jobs!

Unit tests:

I think that what you describe is more work than you really need. Really we only need to exercise config parsing, basic query use, templating in the query, and error handling (including empty value returns). As a nice-to-have, it would be helpful to have a test in a ComparatorValidator.

Autoload:

Option 2, please! This way it's possible to successfully run PyRestTest without the Jmespath library installed, but if the library is present the extension is available. And yes, the example you gave really is all you need there (the extension loader looks for registry variables) -- both snippets look correct to me at first glance.

On consideration, it would be nice to convert the extension_use_test.sh to some form of functional test for autoloading extensions (that only runs if libraries are present)... but I'm certainly not making that a PR requirement since it's something I ought to have done a while back.

Documentation:

Ha! It doesn't even need that much, just an example of syntax for using the extractor and what that returns, and a link to the tutorial there.

Hope that makes your life easier as you have a fulltime job too! ;-)

@marklz
Copy link

marklz commented Dec 3, 2015

Can't register extension - what am I doing wrong?

[mzusman@pioneer-dev36 pyresttest]$ pyresttest http://172.17.230.6 aa.yaml --import_extensions 'ext.extractor_jmespath'
Traceback (most recent call last):
  File "/usr/bin/pyresttest", line 4, in <module>
    resttest.command_line_run(sys.argv[1:])
  File "/usr/lib/python2.6/site-packages/pyresttest/resttest.py", line 863, in command_line_run
    main(args)
  File "/usr/lib/python2.6/site-packages/pyresttest/resttest.py", line 769, in main
    register_extensions(extensions)
  File "/usr/lib/python2.6/site-packages/pyresttest/resttest.py", line 709, in register_extensions
    module = __import__(ext, globals(), locals(), package)
ImportError: No module named extractor_jmespath
[mzusman@pioneer-dev36 pyresttest]$ !ls
ls -ltr ext
total 20
-rw-rw-r-- 1 mzusman mzusman 1890 Nov 30 20:28 validator_jsonschema.py
-rw-rw-r-- 1 mzusman mzusman  126 Nov 30 20:28 __init__.py
-rw-rw-r-- 1 mzusman mzusman 1068 Dec  3 11:15 extractor_jmespath.py
-rw-rw-r-- 1 mzusman mzusman  296 Dec  3 11:41 __init__.pyc
-rw-rw-r-- 1 mzusman mzusman 1676 Dec  3 11:41 extractor_jmespath.pyc

@svanoort
Copy link
Owner Author

svanoort commented Dec 5, 2015

@marklz Simple, if it's using the --import_extensions argument, the path should be pyresttest.ext.extractor_jmespath, I believe -- it's relative to current folder. If you're doing import like the jsonschema extractor, it's of course going to omit the first bit.

@marklz
Copy link

marklz commented Dec 6, 2015

Good news - autoloader works all is well; I was running pyresttest - which was /usr/bin/pyrestest!
Once I started doing

python resttest.py http://172.17.230.6 aa.yaml

we started to pick up correct code. Without any modifications, run my initial test - first two tests run correctly, throws exception on 3rd one. I'll debug it tomorrow, but it's a nice progress.

- test:
    - name: "correct url, correct authentication"
    - url: "/api/1.0/config/element/terminal/"
    - auth_username: "admin"
    - auth_password: "admin"
    - expected_status: [200]
    - validators:
        - compare: {jmespath: 'errors', comparator: 'count_eq', expected: 0 }
        - compare: {jmespath: 'meta.count', comparator: 'gt', expected: 0 }
        - compare: {jmespath: 'data.0.obj_attributes.mgmtsubnetmask', comparator: 'regex', expected: '255.255.255.*' }

@svanoort
Copy link
Owner Author

svanoort commented Dec 7, 2015

@marklz Excellent!

@marklz
Copy link

marklz commented Dec 7, 2015

I'm going over http://jmespath.org/tutorial.html and it looks good so far:

  • I've created a few simple input files
[mzusman@pioneer-dev36 pyresttest]$ for f in *; do echo ====== $f =====; cat $f; done
====== jmespath-test-1 =====
{"a": "foo", "b": "bar", "c": "baz"}
====== jmespath-test-2 =====
{"a": {"b": {"c": {"d": "value"}}}}
====== jmespath-test-3 =====
["a", "b", "c", "d", "e", "f"]
====== jmespath-test-4 =====
{"a": {
  "b": {
    "c": [
      {"d": [0, [1, 2]]},
      {"d": [3, 4]}
    ]
  }
}}
====== jmespath-test-5 =====
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
====== jmespath-test-6 =====
{
  "people": [
    {"first": "James", "last": "d"},
    {"first": "Jacob", "last": "e"},
    {"first": "Jayden", "last": "f"},
    {"missing": "different"}
  ],
  "foo": {"bar": "baz"}
}
  • I have a test configuration
[mzusman@pioneer-dev36 pyresttest]$ cat aa.yaml
---
- config:
    - testset: "Basic tests"

- test:
    - name: "test-1"
    - url: "/jmespath-test-1"
    - expected_status: [200]
    - validators:
        - compare: {jmespath: 'a', comparator: 'eq', expected: 'foo' }
        - compare: {jmespath: 'b', comparator: 'eq', expected: 'bar' }
        - compare: {jmespath: 'c', comparator: 'eq', expected: 'baz' }

- test:
    - name: "test-2"
    - url: "/jmespath-test-2"
    - expected_status: [200]
    - validators:
        - compare: {jmespath: 'a.b.c.d', comparator: 'eq', expected: 'value' }

- test:
    - name: "test-3"
    - url: "/jmespath-test-3"
    - expected_status: [200]
    - validators:
        - compare: {jmespath: '[1]', comparator: 'eq', expected: 'b' }

- test:
    - name: "test-4"
    - url: "/jmespath-test-4"
    - expected_status: [200]
    - validators:
        - compare: {jmespath: 'a.b.c[0].d[1][0]', comparator: 'eq', expected: 1 }


- test:
    - name: "test-5"
    - url: "/jmespath-test-5"
    - expected_status: [200]
    - validators:
        - compare: {jmespath: 'length([0:5])', comparator: 'eq', expected: 5 }
        - compare: {jmespath: '[1:3]', comparator: 'eq', expected: '[1, 2]' }
        - compare: {jmespath: '[::2]', comparator: 'eq', expected: '[0, 2, 4, 6, 8]' }
        - compare: {jmespath: '[5:0:-1]', comparator: 'eq', expected: '[5, 4, 3, 2, 1]' }

- test:
    - name: "test-6"
    - url: "/jmespath-test-6"
    - expected_status: [200]
    - validators:
        - compare: {jmespath: 'people[*].first', comparator: 'eq', expected: "['James', 'Jacob', 'Jayden']" }
        - compare: {jmespath: 'people[:2].first', comparator: 'eq', expected: "['James', 'Jacob']" }

  • everything passes
[mzusman@pioneer-dev36 pyresttest]$ python resttest.py http://pioneer-dev36.eng.idirect.net/~mzusman/pyresttest aa.yaml |& tee aa
1: jmespath : query = a  result = foo type = <type 'str'>
1: jmespath : query = b  result = bar type = <type 'str'>
1: jmespath : query = c  result = baz type = <type 'str'>
1: jmespath : query = a.b.c.d  result = value type = <type 'str'>
1: jmespath : query = [1]  result = b type = <type 'str'>
1: jmespath : query = a.b.c[0].d[1][0]  result = 1 type = <type 'int'>
1: jmespath : query = length([0:5])  result = 5 type = <type 'int'>
1: jmespath : query = [1:3]  result = [1, 2] type = <type 'list'>
1: jmespath : query = [::2]  result = [0, 2, 4, 6, 8] type = <type 'list'>
1: jmespath : query = [5:0:-1]  result = [5, 4, 3, 2, 1] type = <type 'list'>
1: jmespath : query = people[*].first  result = ['James', 'Jacob', 'Jayden'] type = <type 'list'>
1: jmespath : query = people[:2].first  result = ['James', 'Jacob'] type = <type 'list'>
Test Group Default SUCCEEDED: 6/6 Tests Passed!

I assume that for real unit tests, I'll have to convert URL requests to reading files - how do I handle from packaging POV? Put input files into ext? Into some other place?

Mark.

@marklz
Copy link

marklz commented Dec 8, 2015

  • Documentation - it will look like this.
  • Unit tests - I'm trying to run them as
python resttest.py --verbose ext/jmespath-test.yaml --log=DEBUG --test=ext/jmespath-test-all

and get no output. When I run against URL, all is well - I get

[mzusman@pioneer-dev36 pyresttest]$  python resttest.py http://pioneer-dev36.eng.idirect.net/~mzusman/pyresttest ext/jmespath-test.yaml
Test Group Default SUCCEEDED: 1/1 Tests Passed!

what am I doing wrong?

Mark.

@svanoort
Copy link
Owner Author

svanoort commented Dec 9, 2015

@marklz This is looking really great! For your real unit tests, we really only need to test that:

  1. JMESPath extension loads (a functional test with very basic query against the included Djano/Tastypie mini-app). If you've already got YAML tests, this example in function tests may help as well.
  2. Unit test that queries are parsed in a few, simple cases using an inline JSON fragment (no need to read long bodies from files). We need to make sure it handles invalid JSON gracefully with exceptions, as well as returning None if the query has no matches.

As far as structure for tests goes: for functional tests (pyresttest/functionaltests.py), add a conditional to the functional tests to run if JMESPath can import, i.e.:

try:
   import JMESPath
   def run_jmespath_test(self):
      do_my_testing_here
catch ImportError:
   pass  # Doesn't run JMESPath test if can't import library

For unit tests, we need a test class in pyresttest/ext/test_jmespath.py.
I'll probably have to tweak the test execution scripts a little bit for that, but there's other use cases too.

Documentation:
This approach works well, but I'd say it doesn't need to be as long - maybe 4-5 examples covering first the simplest case and then a couple other smaller ones. They can always refer to the JMESPath docs for more complex syntax examples.

Running without URL:
This isn't supposed to work, if it's not generating output indicating you need both a URL and a test file, then I think that's a bug (and I need to investigate it).

@marklz
Copy link

marklz commented Dec 10, 2015

I fixed cut down on documentation, but I'm unclear on where to put unit tests. I see two options:

  • add something like test_validator_error_responses(), only with more tests; I think that this is what you were talking about when you mentioned "Unit test that queries are parsed in a few, simple cases using an inline JSON fragment" - and this goes into pyresttest/ext/test_jmespath.py. Here I'm all clear what to do.
  • something that goes into into pyresttest/functionaltests.py and has conditional import and all that - this is where I'm not quite sure how to code it.
  • as for invocation without output, it works as design - here's code in parsing command line arguments
    # Handle url/test as named, or, failing that, positional arguments
    if not args['url'] or not args['test']:
        if len(unparsed_args) == 2:
            args[u'url'] = unparsed_args[0]
            args[u'test'] = unparsed_args[1]
        elif len(unparsed_args) == 1 and args['url']:
            args['test'] = unparsed_args[0]
        elif len(unparsed_args) == 1 and args['test']:
            args['url'] = unparsed_args[0]
        else:
            parser.print_help()
            parser.error(
                "wrong number of arguments, need both url and test filename, either as 1st and 2nd parameters or via --url and --test")

we have only length(unparsed_args) == 1, unparsed_args[0] is ext/jmespath-test.yaml, it's assumed to be url, which is latter happily opened and processed...

Mark.

@svanoort
Copy link
Owner Author

something like test_validator_error_responses()

Exactly! And also something like test_parse_validator_extracttest.

something that goes into into pyresttest/functionaltests.py

That's the snippet I posted earlier, with the imports. Because imports don't have to be at the top of python files and can be executed conditionally, like I'd listed earlier

try:
   import JMESPath
   def run_jmespath_test(self):
      do_my_testing_here
catch ImportError:
   pass  # Doesn't run JMESPath test if can't import library

Except do_my_testing_here is something similar to: https://github.com/svanoort/pyresttest/blob/master/pyresttest/functionaltest.py#L111

At least, I think this should work, I have not actually tested it yet!

Also pay no mind to how horrifyingly awful some of the other test syntax is -- eventually I need to rework the internal Python APIs to be somewhat more elegant (probably mimicking frisby.js a bit), but that's going to be a future-roadmap thing, around the PyRestTest 2.0 release probably. ;-)

as for invocation without output, it works as design

D'oh!

@svanoort svanoort changed the title JMESPath Extractor JMESPath Extractor - in progress under marklz Dec 11, 2015
@svanoort svanoort modified the milestones: 1.7.0 - Python 3 + Parsing/Configuration Internals, Misc Enhancements Dec 15, 2015
@marklz
Copy link

marklz commented Dec 15, 2015

I somehow manage to fail in both tests:

  • test_validators.py - I've added code
    def test_parse_validator_jmespath_extracttest(self):
        """ Test parsing for jmespath extract test """
        config = {
            'jmespath': 'key.val',
            'test': 'exists'
        }
        myjson_pass = '{"id": 3, "key": {"val": 3}}'
        myjson_fail = '{"id": 3, "key": {"valley": "wide"}}'
        validator = validators.ExtractTestValidator.parse(config)

and get


Traceback (most recent call last):
  File "test_validators.py", line 493, in test_parse_validator_jmespath_extracttest
    validator = validators.ExtractTestValidator.parse(config)
  File "/home/mzusman/work/pyresttest/pyresttest/validators.py", line 454, in parse
    extractor = _get_extractor(config)
  File "/home/mzusman/work/pyresttest/pyresttest/validators.py", line 307, in _get_extractor
    'No valid extractor name to use in input: {0}'.format(config_dict))
Exception: No valid extractor name to use in input: {'test': 'exists', 'jmespath': 'key.val'}

How do I force loading of jmespath extractor?

  • functionaltest.py - it asks for django - do I have to set it up, together with local webserver?
+ python pyresttest/functionaltest.py
Traceback (most recent call last):
  File "pyresttest/functionaltest.py", line 10, in <module>
    from django.core.management import call_command
ImportError: No module named django.core.management

@svanoort
Copy link
Owner Author

For the test_validators unit test, the problem is very simple: resttest.py isn't loaded by the validators module, so the extension is never loaded. Resttest can't be imported there, since resttest imports validators, and if they load each other we get an endless loop (this is why the functional test exists, to exercise the auto-import).

The solution is simple: for the extension test, we'll need to explicitly do the try-import section (in case the library isn't installed) and then at the beginning of the test call the validators.register_extractor (after importing the extension): https://github.com/marklz/pyresttest/blob/master/pyresttest/validators.py#L542

Be warned, it may be a little tricky to get the import right for this!

For the functional test:
I've got instructions for how to build and test but you'll basically need (on python 2.7):

sudo pip install 'django >=1.6, <1.7' django-tastypie pycurl pyyaml mock

@svanoort
Copy link
Owner Author

@marklz ^

@marklz
Copy link

marklz commented Dec 21, 2015

Good news - test_validators unit test is done
Bad news - I'm still buffled by functional test. This is where I am:

  • code in functionaltest.py
    def test_get_validators_jmespath_fail(self):
        """ Test validators that should fail """
        test = Test()
        test.url = self.prefix + '/api/person/'
        test.validators = list()
        print 'jmespath validators: ' + str(test.validators)
        cfg_exists = {'jmespath': 'meta.limit', 'test': 'exists'}
        test.validators.append(
            validators.parse_validator('extract_test', cfg_exists))
        test_response = resttest.run_test(test)
        print 'jmespath response: ' + str(test_response)

result of execution (I've added spaces for readability):

...
[21/Dec/2015 23:26:48] "GET /api/person/ HTTP/1.1" 200 436
.jmespath validators: []
{
   "meta": 
     {"limit": 20, "next": null, "offset": 0, "previous": null, "total_count": 3}, 
   "objects": [
      {"first_name": "Gaius", "id": 1, "last_name": "Baltar", "login": "gbaltar", "resource_uri": "/api/person/1/"}, 
      {"first_name": "Leeroy", "id": 2, "last_name": "Jenkins", "login": "jenkins", "resource_uri": "/api/person/2/"}, 
      {"first_name": "Bilbo", "id": 3, "last_name": "Baggins", "login": "bbaggins", "resource_uri": "/api/person/3/"}
   ]
}
[('date', 'Mon, 21 Dec 2015 23:26:48 GMT'), ('server', 'WSGIServer/0.1 Python/2.6.6'), ('vary', 'Accept'), ('x-frame-options', 'SAMEORIGIN'), ('content-type', 'application/json'), ('cache-control', 'no-cache')]
jmespath response: 
{
   "body": 
    "{\"meta\": 
       {\"limit\": 20, \"next\": null, \"offset\": 0, \"previous\": null, \"total_count\": 3}, 
   \"objects\": [
       {\"first_name\": \"Gaius\", \"id\": 1, \"last_name\": \"Baltar\", \"login\": \"gbaltar\", \"resource_uri\": \"/api/person/1/\"}, 
      {\"first_name\": \"Leeroy\", \"id\": 2, \"last_name\": \"Jenkins\", \"login\": \"jenkins\", \"resource_uri\": \"/api/person/2/\"}, 
    {\"first_name\": \"Bilbo\", \"id\": 3, \"last_name\": \"Baggins\", \"login\": \"bbaggins\", \"resource_uri\": \"/api/person/3/\"}
   ]
}", 
"response_headers": [
    ["date", "Mon, 21 Dec 2015 23:26:48 GMT"], 
    ["server", "WSGIServer/0.1 Python/2.6.6"], 
    ["vary", "Accept"], 
    ["x-frame-options", "SAMEORIGIN"], 
    ["content-type", "application/json"], 
    ["cache-control", "no-cache"]
], 
"response_code": 200, 
"passed": false, 
"test": {
   "validators": [
        {  "extractor": {"query": "meta.limit", "is_templated": false}, "test_fn": {}, 
            "config": {"test": "exists", "jmespath": "meta.limit"}, "test_name": "exists"
        }
   ], 
"expected_status": [200], 
"_headers": {}, 
"_url": "http://localhost:8000/api/person/", 
"templated": {}}, 
"failures": [
   {
      "failure_type": "Extractor Exception", 
       "message": "Exception thrown while running extraction from body", 
        "validator": {"extractor": {"query": "meta.limit", "is_templated": false}, "test_fn": {}, "config": {"test": "exists", "jmespath": "meta.limit"}, "test_name": "exists"}, 
      "details": "Traceback (most recent call last):\n  
          File \"/home/mzusman/work/pyresttest/pyresttest/validators.py\", line 466, in validate\n    
                   body=body, headers=headers, context=context)\n  
          File \"/home/mzusman/work/pyresttest/pyresttest/validators.py\", line 177, in extract\n    
                   return self.extract_internal(query=query, body=body, headers=headers, args=self.args)\n  
          File \"/home/mzusman/work/pyresttest/pyresttest/ext/extractor_jmespath.py\", line 39, in extract_internal\n    raise ValueError(\"Invalid query: \" + query + \" : \" + str(e))\nValueError: Invalid query: meta.limit : malformed string\n"
     }
 ]
}

What am I doing wrong? I've tried to specify any query I can think of, but jmespath rejects it anyway as malformed string....

Mark.

@svanoort
Copy link
Owner Author

@marklz Wanna jump on gitter and discuss? I'm at a local python project night and now would be perfect timing! :-)

@svanoort
Copy link
Owner Author

@marklz Specifically, https://gitter.im/svanoort/pyresttest

@svanoort
Copy link
Owner Author

@marklz I've got the fix (tested with a clone of your code):

This: https://github.com/marklz/pyresttest/blob/master/pyresttest/ext/extractor_jmespath.py#L30

Should instead be:
res = jmespath.search(query, json.loads( body ) )

Ast.eval and json.loads are not interchangeable (the first loads a python object, the second loads json). The two syntaxes are very similar but not interchangeable.

@svanoort
Copy link
Owner Author

svanoort commented Mar 5, 2016

@marklz It's now merged in; whew, let it never be said that dual python 2/3 compatibility is easy! Had to make some changes to get everything to play nicely now.

@svanoort svanoort closed this as completed Mar 5, 2016
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants