https://github.com/horgix/willish
A simple wishlist API
https://github.com/horgix/willish
Last synced: 10 months ago
JSON representation
A simple wishlist API
- Host: GitHub
- URL: https://github.com/horgix/willish
- Owner: Horgix
- Created: 2017-10-23T22:06:47.000Z (over 8 years ago)
- Default Branch: master
- Last Pushed: 2017-10-24T22:00:57.000Z (over 8 years ago)
- Last Synced: 2025-10-08T16:02:46.549Z (10 months ago)
- Language: JavaScript
- Size: 41 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
Warning, this is being developed, not even alpha yet.
# Introduction
This `README.md` covers all the development process of this appplication.
It is intended to later be transformed into a blog post of some kind, giving
people a real world example of the development process of a simple but clean
app.
# Methods
We're going to define the following endpoints on ou API:
- `GET http://example.com/wishes`
- Obtain information about a resource
- Retrieve wishes list
- `GET http://example.com/wishes/123`
- Obtain information about a resource
- Retrieve wish #123
- `POST http://example.com/wishes`
- Create a new resource
- Create a new wish, from data provided with the request
- `PUT http://example.com/wishes/123`
- Update a resource
- Update wish #123, from data provided with the request
- `DELETE http://example.com/wishes/123`
- Delete a resource
- Delete wish #123
# API versioning
This will be taken care of in a later release, with proper version in
Accepted-Content, etc., not in URL
# Methods
- GET `http://[hostname]/wishes` Retrieve list of wishes
- GET `http://[hostname]/wishes/[wish_id]` Retrieve a wish based on its ID
- POST `http://[hostname]/wishes` Create a new wish, from data
provided with the request
- PUT `http://[hostname]/wishes/[wish_id]` Update an existing wish based
on its ID, from data provided with the request
- DELETE `http://[hostname]/wishes/[wish_id]` Delete a wish based on its ID
# What's a wish?
First question we have to ask ourselves: **what is a wish**?
It's nice and everything to talk about API, versionning, bla, bla, bla, but we
have to start from the functionnal (some would say "business") side: what is
the purpose and the needs of what we're building?
A *Wish* is something we thrive to acquire. Well, you get the point of a
wishlist I guess.
Let's define a *Wish* as follow:
- `id`: Numeric. Unique identifier. Generated.
- `name`: String. *Wish* item name.
- `link`: String. Link to somewhere the wish can be found/bought.
- `acquired`: Bool. *Wish* obtention status.
This may, of course, be expanded later one. Here are some examples:
- Price
- Multiple links
# Virtualenv
Let's dive a bit in the code!
```
make venv
```
# Dependencies
`requirements.txt` contains a pip freeze
# What I did
`pip install flask`
```raw
$ pip install flask
Collecting flask
Downloading Flask-0.12.1-py2.py3-none-any.whl (82kB)
100% |████████████████████████████████| 92kB 4.6MB/s
Collecting click>=2.0 (from flask)
Downloading click-6.7-py2.py3-none-any.whl (71kB)
100% |████████████████████████████████| 71kB 7.2MB/s
Collecting itsdangerous>=0.21 (from flask)
Downloading itsdangerous-0.24.tar.gz (46kB)
100% |████████████████████████████████| 51kB 9.2MB/s
Collecting Werkzeug>=0.7 (from flask)
Downloading Werkzeug-0.12.1-py2.py3-none-any.whl (312kB)
100% |████████████████████████████████| 317kB 1.5MB/s
Collecting Jinja2>=2.4 (from flask)
Using cached Jinja2-2.9.6-py2.py3-none-any.whl
Collecting MarkupSafe>=0.23 (from Jinja2>=2.4->flask)
Building wheels for collected packages: itsdangerous
Running setup.py bdist_wheel for itsdangerous ... done
Stored in directory: /home/horgix/.cache/pip/wheels/fc/a8/66/24d655233c757e178d45dea2de22a04c6d92766abfb741129a
Successfully built itsdangerous
Installing collected packages: click, itsdangerous, Werkzeug, MarkupSafe, Jinja2, flask
Successfully installed Jinja2-2.9.6 MarkupSafe-1.0 Werkzeug-0.12.1 click-6.7 flask-0.12.1 itsdangerous-0.24
```
`afdd9879e7dc104ab6fa707103e957c553624467`
`chmod +x willish.py`
```raw
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
127.0.0.1 - - [03/May/2017 13:31:17] "GET / HTTP/1.1" 200 -
```
```raw
$ curl localhost:5000
Hello, World!%
```
and it's already handling HEAD for us :D
# GET /wishes
Add details about `curl -i`
```raw
$ curl -i localhost:5000/wishes
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 357
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Wed, 03 May 2017 13:37:17 GMT
```
```json
{
"wishes": [
{
"acquired": true,
"id": 1,
"link": "https://duck-duck-go.myshopify.com/collections/frontpage/products/duckduckgo-t-shirt",
"name": "DuckDuckGo t-shirt"
},
{
"acquired": false,
"id": 2,
"link": "https://supporters.eff.org/shop/eff-lapel-pin",
"name": "EFF pin"
}
]
}
```
```raw
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
127.0.0.1 - - [03/May/2017 15:37:03] "GET /wishes/1 HTTP/1.1" 404 -
127.0.0.1 - - [03/May/2017 15:37:17] "GET /wishes HTTP/1.1" 200 -
```
btw 404
```raw
$ curl -i localhost:5000/wishes/1
HTTP/1.0 404 NOT FOUND
Content-Type: text/html
Content-Length: 233
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Wed, 03 May 2017 13:37:03 GMT
404 Not Found
Not Found
The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.
```
# Add GET /wishes/
```python
@app.route('/wishes/', methods=['GET'])
def get_wish(wish_id):
return jsonify({'wish': wishes[wish_id]})
```
```raw
$ curl -i localhost:5000/wishes/1
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 142
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Wed, 03 May 2017 13:57:24 GMT
```
```json
{
"wish": {
"acquired": false,
"id": 2,
"link": "https://supporters.eff.org/shop/eff-lapel-pin",
"name": "EFF pin"
}
}
```
Fine ! (At least we believe it. I intentionally introduced a bug to be fixed
later and which will give us a reason for writing good tests)
Now an unknown id...
```raw
$ curl -i localhost:5000/wishes/2
HTTP/1.0 500 INTERNAL SERVER ERROR
Content-Type: text/html; charset=utf-8
X-XSS-Protection: 0
Connection: close
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Wed, 03 May 2017 13:57:44 GMT
```
```html
IndexError: list index out of range // Werkzeug Debugger
var TRACEBACK = 140000030783080,
CONSOLE_MODE = false,
EVALEX = true,
EVALEX_TRUSTED = false,
SECRET = "GeFqnZX5UMpgchZHapHh";
builtins.IndexError
IndexError: list index out of range
Traceback (most recent call last)
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py",
line 1997,
in __call__
error = None
ctx.auto_pop(error)
def __call__(self, environ, start_response):
"""Shortcut for :attr:`wsgi_app`."""
return self.wsgi_app(environ, start_response)
def __repr__(self):
return '<%s %r>' % (
self.__class__.__name__,
self.name,
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py",
line 1985,
in wsgi_app
try:
try:
response = self.full_dispatch_request()
except Exception as e:
error = e
response = self.handle_exception(e)
except:
error = sys.exc_info()[1]
raise
return response(environ, start_response)
finally:
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py",
line 1540,
in handle_exception
# if we want to repropagate the exception, we can attempt to
# raise it with the whole traceback in case we can do that
# (the function was actually called from the except part)
# otherwise, we just raise the error again
if exc_value is e:
reraise(exc_type, exc_value, tb)
else:
raise e
self.log_exception((exc_type, exc_value, tb))
if handler is None:
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py",
line 33,
in reraise
from io import StringIO
def reraise(tp, value, tb=None):
if value.__traceback__ is not tb:
raise value.with_traceback(tb)
raise value
implements_to_string = _identity
else:
text_type = unicode
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py",
line 1982,
in wsgi_app
ctx = self.request_context(environ)
ctx.push()
error = None
try:
try:
response = self.full_dispatch_request()
except Exception as e:
error = e
response = self.handle_exception(e)
except:
error = sys.exc_info()[1]
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py",
line 1614,
in full_dispatch_request
request_started.send(self)
rv = self.preprocess_request()
if rv is None:
rv = self.dispatch_request()
except Exception as e:
rv = self.handle_user_exception(e)
return self.finalize_request(rv)
def finalize_request(self, rv, from_error_handler=False):
"""Given the return value from a view function this finalizes
the request by converting it into a response and invoking the
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py",
line 1517,
in handle_user_exception
return self.handle_http_exception(e)
handler = self._find_error_handler(e)
if handler is None:
reraise(exc_type, exc_value, tb)
return handler(e)
def handle_exception(self, e):
"""Default exception handling that kicks in when an exception
occurs that is not caught. In debug mode the exception will
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py",
line 33,
in reraise
from io import StringIO
def reraise(tp, value, tb=None):
if value.__traceback__ is not tb:
raise value.with_traceback(tb)
raise value
implements_to_string = _identity
else:
text_type = unicode
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py",
line 1612,
in full_dispatch_request
self.try_trigger_before_first_request_functions()
try:
request_started.send(self)
rv = self.preprocess_request()
if rv is None:
rv = self.dispatch_request()
except Exception as e:
rv = self.handle_user_exception(e)
return self.finalize_request(rv)
def finalize_request(self, rv, from_error_handler=False):
-
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py",
line 1598,
in dispatch_request
# request came with the OPTIONS method, reply automatically
if getattr(rule, 'provide_automatic_options', False) \
and req.method == 'OPTIONS':
return self.make_default_options_response()
# otherwise dispatch to the handler for that endpoint
return self.view_functions[rule.endpoint](**req.view_args)
def full_dispatch_request(self):
"""Dispatches the request and on top of that performs request
pre and postprocessing as well as HTTP exception catching and
error handling.
-
File "/home/horgix/work/willish/willish.py",
line 28,
in get_wish
def get_wishes():
return jsonify({'wishes': wishes})
@app.route('/wishes/<int:wish_id>', methods=['GET'])
def get_wish(wish_id):
return jsonify({'wish': wishes[wish_id]})
@app.route('/')
def index():
return "Hello, World!"
IndexError: list index out of range
This is the Copy/Paste friendly version of the traceback.
Traceback (most recent call last):
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1997, in __call__
return self.wsgi_app(environ, start_response)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1985, in wsgi_app
response = self.handle_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1540, in handle_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1982, in wsgi_app
response = self.full_dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1614, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1517, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1612, in full_dispatch_request
rv = self.dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1598, in dispatch_request
return self.view_functions[rule.endpoint](**req.view_args)
File "/home/horgix/work/willish/willish.py", line 28, in get_wish
return jsonify({'wish': wishes[wish_id]})
IndexError: list index out of range
The debugger caught an exception in your WSGI application. You can now
look at the traceback which led to the error.
If you enable JavaScript you can also use additional features such as code
execution (if the evalex feature is enabled), automatic pasting of the
exceptions and much more.
Console Locked
The console is locked and needs to be unlocked by entering the PIN.
You can find the PIN printed out on the standard output of your
shell that runs the server.
PIN:
```
IMAGE1
and on server side :
```
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
127.0.0.1 - - [03/May/2017 15:57:24] "GET /wishes/1 HTTP/1.1" 200 -
127.0.0.1 - - [03/May/2017 15:57:44] "GET /wishes/2 HTTP/1.1" 500 -
Traceback (most recent call last):
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1997, in __call__
return self.wsgi_app(environ, start_response)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1985, in wsgi_app
response = self.handle_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1540, in handle_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1982, in wsgi_app
response = self.full_dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1614, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1517, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1612, in full_dispatch_request
rv = self.dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1598, in dispatch_request
return self.view_functions[rule.endpoint](**req.view_args)
File "/home/horgix/work/willish/willish.py", line 28, in get_wish
return jsonify({'wish': wishes[wish_id]})
IndexError: list index out of range
```
But if we change this:
```python
if __name__ == '__main__':
app.run(debug=True)
```
to that:
```python
if __name__ == '__main__':
app.run(debug=False)
```
We just get the bare Error :
```
$ curl -i localhost:5000/wishes/2
HTTP/1.0 500 INTERNAL SERVER ERROR
Content-Type: text/html
Content-Length: 291
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Wed, 03 May 2017 13:59:28 GMT
500 Internal Server Error
Internal Server Error
The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.
```
One more reason to tune errors sent by Flask when working on an API!
Why did it fail?
Well, we aren't checking that the ID is valid.
# Adding error handling on this
```python
@app.route('/wishes/', methods=['GET'])
def get_wish(wish_id):
try:
return jsonify({'wish': wishes[wish_id]})
except IndexError:
return jsonify({'error': 'index not found'})
```
```raw
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
127.0.0.1 - - [03/May/2017 16:03:13] "GET /wishes/2 HTTP/1.1" 200 -
```
And it's working :
```raw
$ curl -i localhost:5000/wishes/2
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 33
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Wed, 03 May 2017 14:03:13 GMT
```
```json
{
"error": "index not found"
}
```
But it's not really HTTP compliant... 200 code for an error...
# Handling error better
Flask to the rescue! `from flask import abort`
Change this:
```python
return jsonify({'error': 'index not found'})
```
into that:
```python
abort(404)
```
and you're done :)
```raw
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
127.0.0.1 - - [03/May/2017 16:16:51] "GET /wishes/2 HTTP/1.1" 404 -
```
```raw
$ curl -i localhost:5000/wishes/2
HTTP/1.0 404 NOT FOUND
Content-Type: text/html
Content-Length: 233
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Wed, 03 May 2017 14:16:51 GMT
```
```html
404 Not Found
Not Found
The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.
```
Yay, 404
But sadly, it's still returning HTML, which is bad for a REST API.
# JSON 404
Thanks to Flask, we can override the error handler that gets called by
`abort()` :)
```python
@app.errorhandler(404)
def not_found(error):
return jsonify({'error': 'Not found'})
```
which seems to be sending JSON:
```raw
$ curl -i localhost:5000/wishes/2
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 27
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Wed, 03 May 2017 14:39:25 GMT
```
```json
{
"error": "Not found"
}
```
But we lost the 404 status again :(
How can we send it?
We'll be using `make_response` in our error handler as suggested in
Documentation:
```python
@app.errorhandler(404)
def not_found(error):
return make_response(jsonify({'error': 'Not found'}), 404)
```
and adding the `make_response` to the `from flask import ...` stuff
and it's working fine :
```raw
$ curl -i localhost:5000/wishes/2
HTTP/1.0 404 NOT FOUND
Content-Type: application/json
Content-Length: 27
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Wed, 03 May 2017 15:42:33 GMT
```
```json
{
"error": "Not found"
}
```
Note the HTTP status code, the Content-Type header and the body. No longer 200,
no longer HTML: we won!
For more information on error handling and JSON outputs:
-
-
We'll come back on that later I guess.
# POST
This will be a long part, but we're entering the really useful part!
First we need to ask ourselves this question : what is mandatory in order to
create a wish? Reminder, we handle the following attributes right now :
- `id` : this will be generated automatically so we'll not take it from POST
request data
- `name` : having a name but no link probably makes sense
- `link` : having a link but no name probably makes sense too
- `acquired` : we'll set it to False at creation time, this will not be taken
either from POST request data. It would be non sense to add an item we
already acquired to a wishlist, wouldn't it?
Sooo, at the end, we require either `name` or `link`, and we should fail if
none of them is provided in the request. For the rest/REST (what a joke.), it's
up to us to decide what we do with parameter we don't need, i.e. if a client
send us a `cake_taste` field or the `id` or `acquired` field which we'll not
take into account anyway. Should we send an error, forcing the client to send
perfectly conform requests? Should we just ignore it? **I'm going for the
ignore thing**, it will be way easier, but we'll have to document it with our
API so people can expect it.
## Implementation
- add request to from flask import ...
Request doc : http://flask.pocoo.org/docs/0.12/api/#incoming-request-data
### First
Let's implement something really dumb simple: we'll just check that provided JSON is
valid before trying to do anything with the JSON data.
Flask provides the `is_json` field with following description
:
> Indicates if this request is JSON or not. By default a request is considered
> to include JSON data if the mimetype is `application/json` or
> `application/*+json`.
```python
@app.route('/wishes', methods=['POST'])
def add_wish():
print(request.is_json)
return jsonify({})
```
Let's see how it behaves:
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard"}' http://localhost:5000/wishes
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 3
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 16:56:17 GMT
{}
```
```
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
True
127.0.0.1 - - [04/May/2017 18:56:17] "POST /wishes HTTP/1.1" 200 -
```
Yay, fine! `is_json` returns `True`.
However, I'm a bit worried about the `Content-Type`-based check. What if
someone sends us a request with `application/json` as `Content-Type` but
without a real JSON object in data? Or an invalid one? Let's try!
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard' http://localhost:5000/wishes
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 3
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 16:54:29 GMT
{}
```
```
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
True
127.0.0.1 - - [04/May/2017 18:58:04] "POST /wishes HTTP/1.1" 200 -
```
Well, `True` too :(
But there is another field, `json`, that can saves us !
> If the mimetype is application/json this will contain the parsed JSON data. Otherwise this will be None.
>
> The get_json() method should be used instead.
Ok, we'll use `get_json()` to access fields, but looks like we can use `json`
to assert for parse-able json.
Let's verify that.
```python
@app.route('/wishes', methods=['POST'])
def add_wish():
print(request.json)
return jsonify({})
```
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard"}' http://localhost:5000/wishes
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 3
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 17:02:42 GMT
{}
```
```
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
{'name': 'New keyboard'}
127.0.0.1 - - [04/May/2017 19:02:42] "POST /wishes HTTP/1.1" 200 -
```
Ok so valid JSON works as expected.
Let's try invalid JSON:
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard' http://localhost:5000/wishes
HTTP/1.0 400 BAD REQUEST
Content-Type: text/html
Content-Length: 203
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 17:03:34 GMT
400 Bad Request
Bad Request
Failed to decode JSON object: Unterminated string starting at: line 1 column 9 (char 8)
```
```
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
127.0.0.1 - - [04/May/2017 19:03:34] "POST /wishes HTTP/1.1" 400 -
```
Nice!
However, once more, we're getting a HTML body, just like for the 404 stuff. You
know how this ends up; let's override the handler for the 400 error !
http://flask.pocoo.org/docs/0.12/errorhandling/#registering
```python
@app.errorhandler(400)
def bad_request(error):
return make_response(jsonify({'error': 'Bad Request'}), 400)
```
```raw
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard' http://localhost:5000/wishes
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json
Content-Length: 29
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 17:11:09 GMT
```
```json
{
"error": "Bad Request"
}
```
```
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 191-272-850
127.0.0.1 - - [04/May/2017 19:12:00] "POST /wishes HTTP/1.1" 400 -
```
It works... but we lost an information here. If you take a look at the HTML
precedently returned, you'll see `
Failed to decode JSON object: Unterminated
string starting at: line 1 column 9 (char 8)
`. This was returning us the
error details!
Wait. Maybe this is because we run the app with `app.run(debug=False)` ? Let's
try with `debug=True` and without our errorhandler.
```raw
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard' http://localhost:5000/wishes
HTTP/1.0 400 BAD REQUEST
Content-Type: text/html
Content-Length: 192
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 17:13:16 GMT
400 Bad Request
Bad Request
The browser (or proxy) sent a request that this server could not understand.
```
```raw
$ ./willish.py
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
127.0.0.1 - - [04/May/2017 19:14:28] "POST /wishes HTTP/1.1" 400 -
```
Ok so the error is handled differently if the debug mode is enabled or not,
which makes sense.
The difference can be found in the `flask/wrappers.py` file, line 167 :
```python
def on_json_loading_failed(self, e):
"""Called if decoding of the JSON data failed. The return value of
this method is used by :meth:`get_json` when an error occurred. The
default implementation just raises a :class:`BadRequest` exception.
.. versionchanged:: 0.10
Removed buggy previous behavior of generating a random JSON
response. If you want that behavior back you can trivially
add it by subclassing.
.. versionadded:: 0.8
"""
ctx = _request_ctx_stack.top
if ctx is not None and ctx.app.config.get('DEBUG', False):
raise BadRequest('Failed to decode JSON object: {0}'.format(e))
raise BadRequest()
```
I actually have to thank JetBrain's PyCharm for finding this, the debugger with
step-by-step run was immensely useful.
So, the BadRequest returned is directly modified when raised according to the
debug mode. So we actually will not to have to handle that by ourselves, cool
:)
Let's see how it goes :
```python
@app.errorhandler(400)
def bad_request(error):
return make_response(jsonify({'error': str(error)}), 400)
```
In `debug=False`:
```raw
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard' http://localhost:5000/wishes
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json
Content-Length: 111
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 21:57:23 GMT
```
```json
{
"error": "400 Bad Request: The browser (or proxy) sent a request that this server could not understand."
}
```
In `debug=True`:
```raw
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard' http://localhost:5000/wishes
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json
Content-Length: 122
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 21:57:32 GMT
```
```json
{
"error": "400 Bad Request: Failed to decode JSON object: Unterminated string starting at: line 1 column 9 (char 8)"
}
```
Exactly what we wanted!
- JSON `Content-Type`
- JSON body in answer
- Real error message, with details in debug mode
Let's do the same for the 404 error and transform this:
```python
@app.errorhandler(404)
def not_found(error):
return make_response(jsonify({'error': 'Not found'}), 404)
```
in that:
```python
@app.errorhandler(404)
def not_found(error):
return make_response(jsonify({'error': str(error)}), 404)
```
So now instead of:
```raw
$ curl -i localhost:5000/wishes/4
HTTP/1.0 404 NOT FOUND
Content-Type: application/json
Content-Length: 27
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 22:02:00 GMT
```
```json
{
"error": "Not found"
}
```
we get:
```raw
$ curl -i localhost:5000/wishes/4
HTTP/1.0 404 NOT FOUND
Content-Type: application/json
Content-Length: 154
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Thu, 04 May 2017 22:03:43 GMT
```
```json
{
"error": "404 Not Found: The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again."
}
```
We'll take a look later at how we could generalize this error handling for JSON.
commit `2c588f1ce864e94ff393d9001d6808891f5f15f5` here
### Second
Well. At the beginning we wanted to implement the POST request and ended up
tuning errors. Let's get back to the POST itself.
After thinking about it, we'll need the JSON data anyway and we'll have to call
`get_json()` so let's substitute `request.json` by `request.get_json()` right
away.
Reminder: we were just checking that JSON was parse-able, but we also need to
assert that the `Content-Type` is `application/json`. If it's not, `get_json()`
will return None so let's check that:
With something like that:
```
@app.route('/wishes', methods=['POST'])
def add_wish():
json = request.get_json()
name = json['name']
return jsonify({'name': name})
```
we end up with
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard"}' http://localhost:5000/wishes
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 29
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 10:05:09 GMT
{
"name": "New keyboard"
}
```
and in case of error in the JSON
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard' http://localhost:5000/wishes
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json
Content-Length: 122
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 10:05:36 GMT
{
"error": "400 Bad Request: Failed to decode JSON object: Unterminated string starting at: line 1 column 9 (char 8)"
}
```
Now let's check with a bad content-type:
```
$ curl -i -H "Content-Type: application/json2" -X POST -d '{"name":"New keyboard"}' http://localhost:5000/wishes
HTTP/1.0 500 INTERNAL SERVER ERROR
Content-Type: text/html; charset=utf-8
X-XSS-Protection: 0
Connection: close
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 10:06:17 GMT
:
> 422 Unprocessable Entity (WebDAV; RFC 4918)
> The request was well-formed but was unable to be followed due to semantic errors
Indeed, 400 Bad Request is fine for bad json, malformed stuff, etc. But for
well formatted requests but with bad data, the 422 fits better.
Let's implement the check.
```
@app.route('/wishes', methods=['POST'])
def add_wish():
json = request.get_json()
if json is None:
abort(400)
if 'name' not in json and 'link' not in json:
abort(422)
return jsonify({'name': 'placeholder'})
```
Of course, just like we did until now, we have to override the error handler
for 422 to make it send JSON :
```
@app.errorhandler(422)
def unprocessable_entity(error):
return make_response(jsonify({'error': str(error)}), 422)
```
Let's test it:
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name2":"New keyboard"}' http://localhost:5000/wishes
HTTP/1.0 422 UNPROCESSABLE ENTITY
Content-Type: application/json
Content-Length: 125
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 13:04:37 GMT
{
"error": "422 Unprocessable Entity: The request was well-formed but was unable to be followed due to semantic errors."
}
```
```
127.0.0.1 - - [05/May/2017 15:04:37] "POST /wishes HTTP/1.1" 422 -
```
Nice!
Now we want to add the real wish in the list, and return it in the data and
with HTTP 201 that stands for "Resource created"
```
@app.route('/wishes', methods=['POST'])
def add_wish():
json = request.get_json()
if json is None:
abort(400)
if 'name' not in json and 'link' not in json:
abort(422)
new_wish = {
'id': 42,
'name': json.get('name'),
'link': json.get('link'),
'acquired': False
}
wishes.append(new_wish)
return jsonify(new_wish), 201
```
Note a few things:
- We use '42' as a placeholder for the ID right now
- the `json.get('name')` and `json.get('link')` will return null if a the field
is not present in the dict, which is fine
- We're setting acquired to False right away, as noted earlier
- We append it to the list of existing wishes before returning anything
- We are returning 201
- We are returning the object we just created
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard"}' http://localhost:5000/wishes
HTTP/1.0 201 CREATED
Content-Type: application/json
Content-Length: 81
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 13:24:14 GMT
{
"acquired": false,
"id": 42,
"link": null,
"name": "New keyboard"
}
```
Wow, it's working! And we should be able to get it by GET-ing /wishes :
```
$ curl -i http://localhost:5000/wishes
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 464
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 13:24:27 GMT
{
"wishes": [
{
"acquired": true,
"id": 1,
"link": "https://duck-duck-go.myshopify.com/collections/frontpage/products/duckduckgo-t-shirt",
"name": "DuckDuckGo t-shirt"
},
{
"acquired": false,
"id": 2,
"link": "https://supporters.eff.org/shop/eff-lapel-pin",
"name": "EFF pin"
},
{
"acquired": false,
"id": 42,
"link": null,
"name": "New keyboard"
}
]
}
```
Yay, it works! However, note that there is absolutely no check Done on the ID
here and its just a placeholder, which allow stuff like that to happen (not
stopping the server from the previous run):
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"link":"http://schwag.archlinux.ca/"}' http://localhost:5000/wishes
HTTP/1.0 201 CREATED
Content-Type: application/json
Content-Length: 96
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 13:30:52 GMT
{
"acquired": false,
"id": 42,
"link": "http://schwag.archlinux.ca/",
"name": null
}
```
```
$ curl -i http://localhost:5000/wishes
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 586
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 13:30:54 GMT
{
"wishes": [
{
"acquired": true,
"id": 1,
"link": "https://duck-duck-go.myshopify.com/collections/frontpage/products/duckduckgo-t-shirt",
"name": "DuckDuckGo t-shirt"
},
{
"acquired": false,
"id": 2,
"link": "https://supporters.eff.org/shop/eff-lapel-pin",
"name": "EFF pin"
},
{
"acquired": false,
"id": 42,
"link": null,
"name": "New keyboard"
},
{
"acquired": false,
"id": 42,
"link": "http://schwag.archlinux.ca/",
"name": null
}
]
}
```
One good thing, one bad!
- Good : we just validated that we can also add tasks with only a link
- Bad : we just created two tasks with the same ID
Long story short, we'll have to handle the ID smartly. And this is not the kind
of things you want to do, we are here just because we're using a poor in memory
"database". In real world, the DB itself would handle that id generation and
uniqueness (and we'll come to this, don't worry).
We're going to do something dirty (sorryyy).
Add a global variable `max_id`:
```
max_id = 2
```
2 since we have 2 wishes in our base "database"
and then increment it and assign the new value to the new wish:
```
max_id += 1
new_id = max_id
new_wish = {
'id': max_id,
'name': json.get('name'),
'link': json.get('link'),
'acquired': False
}
```
This doesn't work :
```
127.0.0.1 - - [05/May/2017 15:37:53] "POST /wishes HTTP/1.1" 500 -
Traceback (most recent call last):
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1997, in __call__
return self.wsgi_app(environ, start_response)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1985, in wsgi_app
response = self.handle_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1540, in handle_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1982, in wsgi_app
response = self.full_dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1614, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1517, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1612, in full_dispatch_request
rv = self.dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1598, in dispatch_request
return self.view_functions[rule.endpoint](**req.view_args)
File "/home/horgix/work/willish/willish.py", line 48, in add_wish
max_id += 1
UnboundLocalError: local variable 'max_id' referenced before assignment
```
Here, it's more global python knowledge than anything else. Long story short
(again), it's due to python scoping and to the fact that we're trying to
reassign a global variable so we need to specify it explicitly by flagging it
as `global`:
```
global max_id
max_id += 1
new_id = max_id
new_wish = {
'id': max_id,
'name': json.get('name'),
'link': json.get('link'),
'acquired': False
}
```
Let's try this :
```
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard"}' http://localhost:5000/wishes
HTTP/1.0 201 CREATED
Content-Type: application/json
Content-Length: 80
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 13:41:52 GMT
{
"acquired": false,
"id": 3,
"link": null,
"name": "New keyboard"
}
$ curl -i -H "Content-Type: application/json" -X POST -d '{"link":"http://schwag.archlinux.ca/"}' http://localhost:5000/wishes
HTTP/1.0 201 CREATED
Content-Type: application/json
Content-Length: 95
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 13:41:55 GMT
{
"acquired": false,
"id": 4,
"link": "http://schwag.archlinux.ca/",
"name": null
}
$ curl -i http://localhost:5000/wishes
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 584
Server: Werkzeug/0.12.1 Python/3.6.0
Date: Fri, 05 May 2017 13:41:59 GMT
{
"wishes": [
{
"acquired": true,
"id": 1,
"link": "https://duck-duck-go.myshopify.com/collections/frontpage/products/duckduckgo-t-shirt",
"name": "DuckDuckGo t-shirt"
},
{
"acquired": false,
"id": 2,
"link": "https://supporters.eff.org/shop/eff-lapel-pin",
"name": "EFF pin"
},
{
"acquired": false,
"id": 3,
"link": null,
"name": "New keyboard"
},
{
"acquired": false,
"id": 4,
"link": "http://schwag.archlinux.ca/",
"name": null
}
]
}
```
Yay,it works :)
We can't just take the greater ID and increment it, else in case of POST, then
DELETE, then POST, we could re-attribute the same ID but to a different wish,
which would be a non-sense.
commit 32f988d16d0533f17149b1efb86742360ab75e33
We'll let the PUT for modification and DELETE for deletion on the side for now.
We already have some stuff to test, so let's go for testing !
Below are a few notes on ideas for later :
## Misc
- What about using/defining some `status.HTTP_NOT_FOUND` to 404 to ensure we
get type right ?
- Initiated after this post : https://blog.miguelgrinberg.com/post/designing-a-restful-api-with-python-and-flask
- Add something about trailing slashes
- Flask capitalizes http status :(
- http://flask.pocoo.org/snippets/83/
- Should we send details of failure reason with error 400 ?
- 422 in case of unsupported Content-Type ?
- Old ids reusability
## Tests ideas
- without trailing slash = trailing slash or redirect
- uneeded post params
- id and acquired params
- Add item with name only
- Add item with link only
- Add item with link and name
- Add, Remove, Add, check ID
- Content type
- JSON badly formatted
# Tests
Let's test!
After searching for a while, I found Chakram
:
From http://dareid.github.io/chakram/ :
> Chakram allows you to write clear and comprehensive tests,
> ensuring JSON REST endpoints work correctly as you develop and in the future
From https://github.com/dareid/chakram :
> Chakram is an API testing framework designed to perform end to end tests on JSON REST endpoints.
This is exactly what we want!
yeah, JSON, I know, but I want to learn more about it, and it looks like this
does the job.
it's based on :
- http://mochajs.org/ , a test runner (it will run the tests and report them,
and allow multiple test "executors" to be managed)
- http://chaijs.com/ , an assertion library which is the "executor" managed by
mocha in context of chakram
## Beginning
let's create a directory "tests"
npm install --save-dev chakram mocha
```
$ npm list [130]~16:28 Fri,May 05┘
/home/horgix/work/willish/tests
├─┬ chakram@1.5.0
│ ├─┬ chai@3.5.0
│ │ ├── assertion-error@1.0.2
│ │ ├─┬ deep-eql@0.1.3
│ │ │ └── type-detect@0.1.1
│ │ └── type-detect@1.0.0
│ ├── chai-as-promised@5.3.0
│ ├── chai-subset@1.5.0
│ ├── extend-object@1.0.0
│ ├── q@1.5.0
│ ├─┬ request@2.81.0
│ │ ├── aws-sign2@0.6.0
│ │ ├── aws4@1.6.0
│ │ ├── caseless@0.12.0
│ │ ├─┬ combined-stream@1.0.5
│ │ │ └── delayed-stream@1.0.0
│ │ ├── extend@3.0.1
│ │ ├── forever-agent@0.6.1
│ │ ├─┬ form-data@2.1.4
│ │ │ ├── asynckit@0.4.0
│ │ │ ├── combined-stream@1.0.5 deduped
│ │ │ └── mime-types@2.1.15 deduped
│ │ ├─┬ har-validator@4.2.1
│ │ │ ├─┬ ajv@4.11.8
│ │ │ │ ├── co@4.6.0
│ │ │ │ └─┬ json-stable-stringify@1.0.1
│ │ │ │ └── jsonify@0.0.0
│ │ │ └── har-schema@1.0.5
│ │ ├─┬ hawk@3.1.3
│ │ │ ├─┬ boom@2.10.1
│ │ │ │ └── hoek@2.16.3 deduped
│ │ │ ├─┬ cryptiles@2.0.5
│ │ │ │ └── boom@2.10.1 deduped
│ │ │ ├── hoek@2.16.3
│ │ │ └─┬ sntp@1.0.9
│ │ │ └── hoek@2.16.3 deduped
│ │ ├─┬ http-signature@1.1.1
│ │ │ ├── assert-plus@0.2.0
│ │ │ ├─┬ jsprim@1.4.0
│ │ │ │ ├── assert-plus@1.0.0
│ │ │ │ ├── extsprintf@1.0.2
│ │ │ │ ├── json-schema@0.2.3
│ │ │ │ └─┬ verror@1.3.6
│ │ │ │ └── extsprintf@1.0.2 deduped
│ │ │ └─┬ sshpk@1.13.0
│ │ │ ├── asn1@0.2.3
│ │ │ ├── assert-plus@1.0.0
│ │ │ ├─┬ bcrypt-pbkdf@1.0.1
│ │ │ │ └── tweetnacl@0.14.5 deduped
│ │ │ ├─┬ dashdash@1.14.1
│ │ │ │ └── assert-plus@1.0.0
│ │ │ ├─┬ ecc-jsbn@0.1.1
│ │ │ │ └── jsbn@0.1.1 deduped
│ │ │ ├─┬ getpass@0.1.7
│ │ │ │ └── assert-plus@1.0.0
│ │ │ ├─┬ jodid25519@1.0.2
│ │ │ │ └── jsbn@0.1.1 deduped
│ │ │ ├── jsbn@0.1.1
│ │ │ └── tweetnacl@0.14.5
│ │ ├── is-typedarray@1.0.0
│ │ ├── isstream@0.1.2
│ │ ├── json-stringify-safe@5.0.1
│ │ ├─┬ mime-types@2.1.15
│ │ │ └── mime-db@1.27.0
│ │ ├── oauth-sign@0.8.2
│ │ ├── performance-now@0.2.0
│ │ ├── qs@6.4.0
│ │ ├── safe-buffer@5.0.1
│ │ ├── stringstream@0.0.5
│ │ ├─┬ tough-cookie@2.3.2
│ │ │ └── punycode@1.4.1
│ │ ├─┬ tunnel-agent@0.6.0
│ │ │ └── safe-buffer@5.0.1 deduped
│ │ └── uuid@3.0.1
│ ├─┬ request-debug@0.2.0
│ │ └── stringify-clone@1.1.1
│ └── tv4@1.3.0
└─┬ mocha@3.3.0
├── browser-stdout@1.3.0
├─┬ commander@2.9.0
│ └── graceful-readlink@1.0.1
├─┬ debug@2.6.0
│ └── ms@0.7.2
├── diff@3.2.0
├── escape-string-regexp@1.0.5
├─┬ glob@7.1.1
│ ├── fs.realpath@1.0.0
│ ├─┬ inflight@1.0.6
│ │ ├── once@1.4.0 deduped
│ │ └── wrappy@1.0.2
│ ├── inherits@2.0.3
│ ├─┬ minimatch@3.0.3
│ │ └─┬ brace-expansion@1.1.7
│ │ ├── balanced-match@0.4.2
│ │ └── concat-map@0.0.1
│ ├─┬ once@1.4.0
│ │ └── wrappy@1.0.2 deduped
│ └── path-is-absolute@1.0.1
├── growl@1.9.2
├── json3@3.3.2
├─┬ lodash.create@3.1.1
│ ├─┬ lodash._baseassign@3.2.0
│ │ ├── lodash._basecopy@3.0.1
│ │ └─┬ lodash.keys@3.1.2
│ │ ├── lodash._getnative@3.9.1
│ │ ├── lodash.isarguments@3.1.0
│ │ └── lodash.isarray@3.0.4
│ ├── lodash._basecreate@3.0.3
│ └── lodash._isiterateecall@3.0.9
├─┬ mkdirp@0.5.1
│ └── minimist@0.0.8
└─┬ supports-color@3.1.2
└── has-flag@1.0.0
```
Wow, that's a lot.
But the npm install fails at the end with:
```
npm WARN enoent ENOENT: no such file or directory, open '/home/horgix/work/willish/tests/package.json'
npm WARN tests No description
npm WARN tests No repository field.
npm WARN tests No README data
npm WARN tests No license field.
```
Ok, looks like the package.json is quite similar to the requirements.txt for
python and that npm can directly append to it, but it doesn't exist yet
turns out you have to `npm init` it before, kind of like `virtualenv` creation;
makes sense.
```
$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
See `npm help json` for definitive documentation on these fields
and exactly what they do.
Use `npm install --save` afterwards to install a package and
save it as a dependency in the package.json file.
Press ^C at any time to quit.
name: (tests) willish-test
version: (1.0.0)
description: Tests for Willish API
entry point: (test.js)
test command: (mocha)
git repository:
keywords:
author: Alexis "Horgix" Chotard
license: (ISC) Beerware
About to write to /home/horgix/work/willish/tests/package.json:
{
"name": "willish-test",
"version": "1.0.0",
"description": "Tests for Willish API",
"main": "test.js",
"dependencies": {
"chakram": "^1.5.0"
},
"devDependencies": {
"mocha": "^3.3.0"
},
"scripts": {
"test": "mocha"
},
"author": "Alexis \"Horgix\" Chotard",
"license": "Beerware"
}
Is this ok? (yes) yes
```
Now if we reinstall chakram and mocha, it only warn like this :
```
npm WARN willish-test@1.0.0 No repository field.
npm WARN The package chakram is included as both a dev and production dependency.
```
which is fine for now.
The `package.json` now automatically contains this:
```
{
"name": "willish-test",
"version": "1.0.0",
"description": "Tests for Willish API",
"main": "test.js",
"dependencies": {
"chakram": "^1.5.0"
},
"devDependencies": {
"chakram": "^1.5.0",
"mocha": "^3.3.0"
},
"scripts": {
"test": "mocha"
},
"author": "Alexis \"Horgix\" Chotard",
"license": "Beerware"
}
```
d81c0233a97a365872942b18988e8def1053e383
Let's try the chakram example:
```
$ cat test.js
var chakram = require('chakram');
describe("Chakram", function() {
it("should offer simple HTTP request capabilities", function () {
return chakram.get("http://httpbin.org/get");
});
});
```
```
$ mocha .
zsh: command not found: mocha
```
Well, contrary to virtualenv, nothing has been set in my PATH so of course it's
not going to find a new command. Thanks StackOverflow here !
http://stackoverflow.com/a/24497202/2781800
And you know what ? When we answered "mocha" to the "test command" in "npm
init", it already put it in the package.json for us! Let's call it
```
$ npm test
> willish-test@1.0.0 test /home/horgix/work/willish/tests
> mocha
Chakram
✓ should offer simple HTTP request capabilities (322ms)
1 passing (332ms)
```
nice.
Let's write out first test!
Chai offers 3 assertions type : expect, should, and assert
They cover the differences really well here : ADD LINK
tl;d
- assert should be avoided if possible
- should is just like expect but with a bit more restrictions
so, as in the example, we'll just use expect for now.
```
var chakram = require('chakram'),
expect = chakram.expect;
describe("Chakram", function() {
it("should answer with success to a basic GET on /wishes", function () {
var response = chakram.get("http://127.0.0.1:5000/wishes");
expect(response).to.have.status(200);
return chakram.wait();
});
});
```
- Do not forget to declare `expect`
- We are just querying our test application directly on `127.0.0.1`, port
`5000`
- We are asserting that the status code is 200, which is OK
```
> npm test
> willish-test@1.0.0 test /home/horgix/work/willish/tests
> mocha
Chakram
✓ should answer with success to a basic GET on /wishes
1 passing (46ms)
```
So it's succeeding!
Let's add a check to ensure the header Content-Type is here
```
var chakram = require("chakram"),
expect = chakram.expect;
describe("Chakram", function() {
it("should answer with success to a basic GET on /wishes", function () {
var response = chakram.get("http://127.0.0.1:5000/wishes");
expect(response).to.have.status(200);
expect(response).to.have.header("Content-Type")
return chakram.wait();
});
});
```
```
> npm test
willish-test@1.0.0 test /home/horgix/work/willish/tests
mocha
Chakram
✓ should answer with success to a basic GET on /wishes (40ms)
1 passing (49ms)
```
Nice. However, something bothers me: if something is going to fail, I'll know
it one fail after the other
for example, let's update the test to this:
```
var chakram = require("chakram"),
expect = chakram.expect;
describe("Chakram", function() {
it("should answer with success to a basic GET on /wishes", function () {
var response = chakram.get("http://127.0.0.1:5000/wishes");
expect(response).to.have.status(500);
expect(response).to.have.header("Content-Type-Wrong-Header")
return chakram.wait();
});
});
```
notice the 500 and Content-Type-Wrong-Header. Both checks will fail
```
$ npm test
> willish-test@1.0.0 test /home/horgix/work/willish/tests
> mocha
Chakram
1) should answer with success to a basic GET on /wishes
0 passing (48ms)
1 failing
1) Chakram should answer with success to a basic GET on /wishes:
AssertionError: expected status code 200 to equal 500
npm ERR! Test failed. See above for more details.
```
and yet we are only getting the information that the status code is wrong, not
the header. It's really oriented business tests more than sort of unit tests.
But I want precise tests! let's try something.
```
var chakram = require("chakram"),
expect = chakram.expect;
describe("GET /wishes", function() {
var apiResponse = chakram.get("http://127.0.0.1:5000/wishes");
it("should return success status code", function () {
return expect(apiResponse).to.have.status(200);
});
it("should have correct 'Content-Type' header", function () {
return expect(apiResponse).to.have.header("Content-Type")
});
});
```
```
> npm test
> willish-test@1.0.0 test /home/horgix/work/willish/tests
> mocha
GET /wishes
✓ should return success status code
✓ should have correct 'Content-Type' header
2 passing (44ms)
```
Nice. We do the request only once, but check the result in separate assertions,
giving us clear logs
However, the following style can be found on Chakram official example
(randomuser):
```
var chakram = require("chakram"),
expect = chakram.expect;
describe("GET /wishes", function() {
var apiResponse;
before(function (){
apiResponse = chakram.get("http://127.0.0.1:5000/wishes");
return apiResponse;
});
it("should return success status code", function () {
return expect(apiResponse).to.have.status(200);
});
it("should have correct 'Content-Type' header", function () {
return expect(apiResponse).to.have.header("Content-Type")
});
});
```
This "before" function is called once before the tests, not before each, so it
behaves exactly as what I did before without this function. What's the
difference ? No idea right now. TODO : check
Here is were we are :
```
var chakram = require("chakram"),
expect = chakram.expect;
describe("GET /wishes", function() {
var apiResponse;
before(function (){
apiResponse = chakram.get("http://127.0.0.1:5000/wishes");
return apiResponse;
});
it("should have success status code", function () {
return expect(apiResponse).to.have.status(200);
});
it("should have correct 'Content-Type' header", function () {
return expect(apiResponse).to.have.header("Content-Type",
"application/json")
});
it("should have 'Server' header", function () {
return expect(apiResponse).to.have.header("Server");
});
it("should have 'Date' header", function () {
return expect(apiResponse).to.have.header("Date");
});
it("should have 'Content-Length' header", function () {
return expect(apiResponse).to.have.header("Content-Length");
});
// TODO : shouldn't leak too much server info
});
```
```
$ npm test
> willish-test@1.0.0 test /home/horgix/work/willish/tests
> mocha
GET /wishes
✓ should have success status code
✓ should have correct 'Content-Type' header
✓ should have 'Server' header
✓ should have 'Date' header
✓ should have 'Content-Length' header
5 passing (47ms)
```
note the 'before' part btw
Ok so we're checking header presence, and that the content-type is
application/json
commit 9b02264160426e13d1c9b713fd455e321b4e9bd5
Now let's check the JSON itself.
Take a look at JSON schema, link doc; etc
JSON schema:
https://spacetelescope.github.io/understanding-json-schema/reference/object.html#required
http://json-schema.org/examples.html
```
it("should have valid JSON as answer", function () {
return expect(apiResponse).to.have.schema({
"type": "object",
"required": ["wishes"],
"properties": {
"wishes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"acquired": { "type": "boolean" },
"id": { "type": "integer" },
"link": { "type": "string" },
"name": { "type": "string" }
}
}
}
}
});
});
});
```
Here we go. This should do the job. Note the "required" part!
TODO: we need to extend it later, after adding some wishes, to make sure they
appear in the list by imposing the wishes array length on the test.
We'll now add tests for the POST function.
The http://dareid.github.io/chakram/example/dweet/ example provides some POST
example.
Now we're onto something really interesting.
I added the following test:
```
describe("POST /wishes", function() {
var apiResponse;
before(function (){
newWish = {
name: "New Keyboard"
};
apiResponse = chakram.post("http://127.0.0.1:5000/wishes", newWish);
return apiResponse;
});
it("should have 'Created' status code", function () {
console.log(apiResponse)
return expect(apiResponse).to.have.status(201);
});
});
```
Simple enough uh?
Well... remember that we can omit name or link?
That's what we just did. And it works!
```
$ npm test
> willish-test@1.0.0 test /home/horgix/work/willish/tests
> mocha
GET /wishes
✓ should have success status code
✓ should have correct 'Content-Type' header
✓ should have 'Server' header
✓ should have 'Date' header
✓ should have 'Content-Length' header
✓ should have valid JSON as answer
POST /wishes
{ state: 'fulfilled',
value:
{ error: null,
response:
IncomingMessage {
_readableState: [Object],
readable: false,
domain: null,
_events: [Object],
_eventsCount: 4,
_maxListeners: undefined,
socket: [Object],
connection: [Object],
httpVersionMajor: 1,
httpVersionMinor: 0,
httpVersion: '1.0',
complete: true,
headers: [Object],
rawHeaders: [Object],
trailers: {},
rawTrailers: [],
upgrade: false,
url: '',
method: null,
statusCode: 201,
statusMessage: 'CREATED',
client: [Object],
_consuming: true,
_dumped: false,
req: [Object],
request: [Object],
toJSON: [Function: responseToJSON],
caseless: [Object],
read: [Function],
body: [Object] },
body: { acquired: false, id: 3, link: null, name: 'New Keyboard' },
jar: RequestJar { _jar: [Object] },
url: 'http://127.0.0.1:5000/wishes',
responseTime: 4.134612 } }
✓ should have 'Created' status code
7 passing (61ms)
```
So, where's the problem?
if we re-run exactly the same tests (the whole tests, GET /wishes + POST)... it
fails!
```
$ npm test 19:53 Sat,May 13┘
> willish-test@1.0.0 test /home/horgix/work/willish/tests
> mocha
GET /wishes
✓ should have success status code
✓ should have correct 'Content-Type' header
✓ should have 'Server' header
✓ should have 'Date' header
✓ should have 'Content-Length' header
1) should have valid JSON as answer
POST /wishes
{ state: 'fulfilled',
value:
{ error: null,
response:
IncomingMessage {
_readableState: [Object],
readable: false,
domain: null,
_events: [Object],
_eventsCount: 4,
_maxListeners: undefined,
socket: [Object],
connection: [Object],
httpVersionMajor: 1,
httpVersionMinor: 0,
httpVersion: '1.0',
complete: true,
headers: [Object],
rawHeaders: [Object],
trailers: {},
rawTrailers: [],
upgrade: false,
url: '',
method: null,
statusCode: 201,
statusMessage: 'CREATED',
client: [Object],
_consuming: true,
_dumped: false,
req: [Object],
request: [Object],
toJSON: [Function: responseToJSON],
caseless: [Object],
read: [Function],
body: [Object] },
body: { acquired: false, id: 4, link: null, name: 'New Keyboard' },
jar: RequestJar { _jar: [Object] },
url: 'http://127.0.0.1:5000/wishes',
responseTime: 4.086063 } }
✓ should have 'Created' status code
6 passing (60ms)
1 failing
1) GET /wishes should have valid JSON as answer:
AssertionError: expected body to match JSON schema {
"type": "object",
"required": [
"wishes"
],
"properties": {
"wishes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"acquired": {
"type": "boolean"
},
"id": {
"type": "integer"
},
"link": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
}
}
}.
-----
error: Invalid type: null (expected string).
data path: /wishes/2/link.
schema path: /properties/wishes/items/properties/link/type.
npm ERR! Test failed. See above for more details.
```
The interesting part is this:
> 1) GET /wishes should have valid JSON as answer:
> data path: /wishes/2/link.
> error: Invalid type: null (expected string).
So the test on JSON schema is failing because `link` is null (we didn't specify
any in our POST, remember!), and we told the schema it need to be a string.
Wow, honestly, tests are really powerful even when we're simply writing them!
Thanks StackOverflow again:
If we change the previous test to:
```
it("should have valid JSON as answer", function () {
return expect(apiResponse).to.have.schema({
"type": "object",
"required": ["wishes"],
"properties": {
"wishes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"acquired": { "type": "boolean" },
"id": { "type": "integer" },
"link": { "type": ["string","null"] },
"name": { "type": ["string","null"] }
}
}
}
}
});
});
```
The test passes again, because we specified that link and name can be a string
OR null. Yet it doesn't ensure that we have one or the other; both shouldn't be
able to be null, and that makes up for another test that we'll see later.
Just to make sure, we're going to add "name" to the main test case and test
edge cases later. Add to this Header and JSON checking to check the "correct"
case fully.
let's also add strict checks on fields that we can predict
```
describe("Basic POST on /wishes", function() {
var apiResponse;
before(function (){
newWish = {
name: "New Keyboard",
link: "http://www.thekeyboardwaffleiron.com/"
};
apiResponse = chakram.post("http://127.0.0.1:5000/wishes", newWish);
return apiResponse;
});
it("should have status code 201", function () {
return expect(apiResponse).to.have.status(201);
});
it("should have 'application/json' as Content-Type", function () {
return expect(apiResponse).to.have.header("Content-Type",
"application/json")
});
it("should have a 'Server' header", function () {
return expect(apiResponse).to.have.header("Server");
});
it("should have a 'Date' header", function () {
return expect(apiResponse).to.have.header("Date");
});
it("should have a 'Content-Length' header", function () {
return expect(apiResponse).to.have.header("Content-Length");
});
it("should have valid JSON in body", function () {
return expect(apiResponse).to.have.schema({
"type": "object",
"required": ["acquired", "id", "link", "name"],
"properties": {
"acquired": { "type": "boolean" },
"id": { "type": "integer" },
"link": { "type": ["string"] },
"name": { "type": ["string"] }
}
});
});
it("should have correct name", function () {
return expect(apiResponse).to.have.json("name", "New Keyboard");
});
it("should have correct link", function () {
return expect(apiResponse).to.have.json("link", "http://www.thekeyboardwaffleiron.com/");
});
it("should have a non-acquired status", function () {
return expect(apiResponse).to.have.json("acquired", false);
});
});
```
We're starting to have a really nice test suite (I renamed a few):
```
$ npm test
> willish-test@1.0.0 test /home/horgix/work/willish/tests
> mocha
GET on /wishes
✓ should have status code 200
✓ should have 'application/json' as Content-Type
✓ should have 'Server' header
✓ should have 'Date' header
✓ should have 'Content-Length' header
✓ should have valid JSON in body
Basic POST on /wishes
✓ should have status code 201
✓ should have 'application/json' as Content-Type
✓ should have a 'Server' header
✓ should have a 'Date' header
✓ should have a 'Content-Length' header
✓ should have valid JSON in body
✓ should have correct name
✓ should have correct link
✓ should have a non-acquired status
15 passing (71ms)
```
let's now check for bad cases.
Let's start with the case were we provide neither name or link, which should
error with 422
```
describe(" POST on /wishes without name or link", function() {
var apiResponse;
before(function (){
newWish = {};
apiResponse = chakram.post("http://127.0.0.1:5000/wishes", newWish);
return apiResponse;
});
it("should have status code 422", function () {
return expect(apiResponse).to.have.status(422);
});
it("should have 'application/json' as Content-Type", function () {
return expect(apiResponse).to.have.header("Content-Type",
"application/json")
});
it("should have error declaration as JSON in body", function () {
return expect(apiResponse).to.have.schema({
"type": "object",
"required": ["error"],
"properties": {
"error": { "type": "string" }
}
});
});
});
```
And with the case were we do not send "application/json" as Content-Type, which
should result in a 400 error
```
describe("POST on /wishes without 'application/json' Content-Type", function() {
var apiResponse;
before(function (){
newWish = {
name: "New Keyboard",
link: "http://www.thekeyboardwaffleiron.com/"
};
apiResponse = chakram.post(
"http://127.0.0.1:5000/wishes",
newWish,
{ headers: {"Content-Type": "invalid/contenttype"}}
);
return apiResponse;
});
it("should have status code 400", function () {
return expect(apiResponse).to.have.status(400);
});
it("should have 'application/json' as Content-Type", function () {
return expect(apiResponse).to.have.header("Content-Type",
"application/json")
});
it("should have error declaration as JSON in body", function () {
return expect(apiResponse).to.have.schema({
"type": "object",
"required": ["error"],
"properties": {
"error": { "type": "string" }
}
});
});
});
```
Nice! Our tests are starting to look good:
```
$ npm test
> willish-test@1.0.0 test /home/horgix/work/willish/tests
> mocha
GET on /wishes
✓ should have status code 200
✓ should have 'application/json' as Content-Type
✓ should have 'Server' header
✓ should have 'Date' header
✓ should have 'Content-Length' header
✓ should have valid JSON in body
Basic POST on /wishes
✓ should have status code 201
✓ should have 'application/json' as Content-Type
✓ should have a 'Server' header
✓ should have a 'Date' header
✓ should have a 'Content-Length' header
✓ should have valid JSON in body
✓ should have correct name
✓ should have correct link
✓ should have a non-acquired status
POST on /wishes without name or link
✓ should have status code 422
✓ should have 'application/json' as Content-Type
✓ should have error declaration as JSON in body
POST on /wishes without 'application/json' Content-Type
✓ should have status code 400
✓ should have 'application/json' as Content-Type
✓ should have error declaration as JSON in body
21 passing (73ms)
```
commit `47a24a4c147abf9e3ca5efa3f82b510161badca7`
As I went to implement the test of unparseable JSON, something interesting
happened:
```
describe("POST on /wishes with unparseable JSON", function() {
var apiResponse;
before(function (){
// Note the missing closing curly bracket and the fact that it's a string
newWish = '{ name: "New Keyboard", link: "http://www.thekeyboardwaffleiron.com/"';
apiResponse = chakram.post(
"http://127.0.0.1:5000/wishes",
newWish
);
return apiResponse;
});
it("should have status code 400", function () {
return expect(apiResponse).to.have.status(400);
});
it("should have 'application/json' as Content-Type", function () {
return expect(apiResponse).to.have.header("Content-Type",
"application/json")
});
it("should have error declaration as JSON in body", function () {
return expect(apiResponse).to.have.schema({
"type": "object",
"required": ["error"],
"properties": {
"error": { "type": "string" }
}
});
});
});
```
Make sure to note the "newWish" content : it's a string that is a JSON without
a closing curly bracket. Invalid and unparseable JSON!
Let's run it
```
POST on /wishes with unparseable JSON
1) should have status code 400
2) should have 'application/json' as Content-Type
3) should have error declaration as JSON in body
[...]
1) POST on /wishes with unparseable JSON should have status code 400:
AssertionError: expected status code 500 to equal 400
2) POST on /wishes with unparseable JSON should have 'application/json' as Content-Type:
AssertionError: expected header Content-Type with value text/html; charset=utf-8 to match application/json
3) POST on /wishes with unparseable JSON should have error declaration as JSON in body:
AssertionError: expected body to match JSON schema {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}.
-----
error: Invalid type: string (expected object).
data path: .
schema path: /type.
```
Wow, 3 fails on 3 tests, that hurts!
1. We got a 500 error back instead of the expected 400
2. We got some content in text/html instead of application/json
3. We didn't get a JSON that matched what we wanted as a schema since, well,
it's html.
If you analyze it a bit... we have a 500 error (1), which we didn't rewrite the
handler for yet, which generates the default error message in html (2) which
fails to match the JSON schema (3).
Why the hell do we have a 500 error?
The logs for the server are the following ones:
```
127.0.0.1 - - [16/May/2017 19:06:28] "POST /wishes HTTP/1.1" 500 -
Traceback (most recent call last):
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1997, in __call__
return self.wsgi_app(environ, start_response)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1985, in wsgi_app
response = self.handle_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1540, in handle_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1982, in wsgi_app
response = self.full_dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1614, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1517, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1612, in full_dispatch_request
rv = self.dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1598, in dispatch_request
return self.view_functions[rule.endpoint](**req.view_args)
File "/home/horgix/work/willish/willish.py", line 54, in add_wish
'name': json.get('name'),
AttributeError: 'str' object has no attribute 'get'
```
why the hell is it calling the json.get('name')? As a reminder, here's the code
atm:
```
@app.route('/wishes', methods=['POST'])
def add_wish():
json = request.get_json()
if json is None:
abort(400)
if 'name' not in json and 'link' not in json:
abort(422)
global max_id
max_id += 1
new_id = max_id
new_wish = {
'id': max_id,
'name': json.get('name'),
'link': json.get('link'),
'acquired': False
}
wishes.append(new_wish)
return jsonify(new_wish), 201
```
We should never reach the part where json.get('name') gets called, it should
exit straight away on this:
```
json = request.get_json()
if json is None:
abort(400)
```
But it doesn't. if we add a `print(json)` just before the condition it shows...
```
{ name: "New Keyboard", link: "http://www.thekeyboardwaffleiron.com/"```
```
The `get_json()` didn't fail, and sent us a plain string!
just to be sure...
```
@app.route('/wishes', methods=['POST'])
def add_wish():
json = request.get_json()
print("Failed wih type: " + str(type(json)))
```
```
Failed wih type:
```
Wait. We checked that kind of stuff when we were still testing the API with
curl, so what changed?
let's print a bit more.
```
@app.route('/wishes', methods=['POST'])
def add_wish():
print("Full request: " + str(request))
print("Request data: " + str(request.data))
print("Request data type: " + str(type(request.data)))
json = request.get_json()
print("Parsed json data type: " + str(type(json)))
if json is None:
abort(400)
[...]
```
And test with curl and chakram:
```raw
$ curl -i -H "Content-Type: application/json" -X POST -d '{"name":"New keyboard' http://localhost:5000/wishes
HTTP/1.0 400 BAD REQUEST
Content-Type: application/json
Content-Length: 122
Server: Werkzeug/0.12.1 Python/3.6.1
Date: Sun, 28 May 2017 19:10:04 GMT
```
```json
{
"error": "400 Bad Request: Failed to decode JSON object: Unterminated string starting at: line 1 column 9 (char 8)"
}
```
and server side:
```raw
Full request:
Request data: b'{"name":"New keyboard'
Request data type:
127.0.0.1 - - [28/May/2017 21:10:38] "POST /wishes HTTP/1.1" 400 -
```
Note that the json parsed stuff never got printed since it failed correctly and
stopped there
```
$ npm test
POST on /wishes with unparseable JSON
1) should have status code 400
2) should have 'application/json' as Content-Type
3) should have error declaration as JSON in body
0 passing (56ms)
3 failing
1) POST on /wishes with unparseable JSON should have status code 400:
AssertionError: expected status code 500 to equal 400
2) POST on /wishes with unparseable JSON should have 'application/json' as Content-Type:
AssertionError: expected header Content-Type with value text/html; charset=utf-8 to match application/json
3) POST on /wishes with unparseable JSON should have error declaration as JSON in body:
AssertionError: expected body to match JSON schema {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}.
-----
error: Invalid type: string (expected object).
data path: .
schema path: /type.
```
```raw
Full request:
Request data: b'"{\\"name\\": \\"New Keyboard\\", \\"link\\": \\"http://www.thekeyboardwaffleiron.com/\\""'
Request data type:
Parsed json data type:
127.0.0.1 - - [28/May/2017 21:11:18] "POST /wishes HTTP/1.1" 500 -
Traceback (most recent call last):
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1997, in __call__
return self.wsgi_app(environ, start_response)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1985, in wsgi_app
response = self.handle_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1540, in handle_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1982, in wsgi_app
response = self.full_dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1614, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1517, in handle_user_exception
reraise(exc_type, exc_value, tb)
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/_compat.py", line 33, in reraise
raise value
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1612, in full_dispatch_request
rv = self.dispatch_request()
File "/home/horgix/work/willish/venv/lib/python3.6/site-packages/flask/app.py", line 1598, in dispatch_request
return self.view_functions[rule.endpoint](**req.view_args)
File "/home/horgix/work/willish/willish.py", line 57, in add_wish
'name': json.get('name'),
AttributeError: 'str' object has no attribute 'get'
```
The difference is quite interesting: chakram (requests actually) chose to make
the string a valid JSON type by enclosing my invalid json in double quotes.
And actually, since it's valid JSON, `get_json()` from flask it totally able to
parse it; hey, it's just a string which is a valid JSON object!
So. 2 things to learn here:
1. We need to address this unpredicted case in our code and throw a proper error
(422) when we get **valid JSON** but which **cannot be parsed as a dict** and
thus cannot be checked against `'name' not in json` and `'link' not in json`
since the parsed object resulting of the `get_json()` operation is just a
string that doesn't implement the `get()` operator. So we need to check if
`json` is actually an instance of a dict, since it can also be a plain bool
(valid json type too)
2. We need to split our test case in 2:
- One that will send only a plain string or bool or whatever, which is
valid json but unexpected parsed type (that will result in a 422)
- One that will send unparseable, invalid JSON (that will result in a 400
and that we were looking to test in the first place)
Let's address 1:
```python
@app.route('/wishes', methods=['POST'])
def add_wish():
json = request.get_json()
if json is None:
abort(400)
if not isinstance(json, dict) or ('name' not in json and 'link' not in json):
# TODO : Maybe add error details if Debug is enabled?
abort(422)
```
I actually thing that json-schema (as used in chakram to check JSON format in
request) may be of use here in the app to check if the JSON received has
requested fields, since this condition is becoming quite long. However I'm not
sure it's able to define stuff like "ensure there is either this field or this
one" like we do with name and link.
Now address 2:
First, the test with a plain string, valid JSON
```node
describe("POST on /wishes with only a string in JSON", function() {
var apiResponse;
before(function (){
// Note the missing closing curly bracket and the fact that it's a string
newWish = 'This is valid JSON since it is only a string, but is not a valid data format for this POST';
apiResponse = chakram.post(
"http://127.0.0.1:5000/wishes",
newWish
);
return apiResponse;
});
it("should have status code 422", function () {
return expect(apiResponse).to.have.status(422);
});
it("should have 'application/json' as Content-Type", function () {
return expect(apiResponse).to.have.header("Content-Type",
"application/json")
});
it("should have error declaration as JSON in body", function () {
return expect(apiResponse).to.have.schema({
"type": "object",
"required": ["error"],
"properties": {
"error": { "type": "string" }
}
});
});
});
```
```raw
POST on /wishes with only a string in JSON
✓ should have status code 422
✓ should have 'application/json' as Content-Type
✓ should have error declaration as JSON in body
```
Yay. Now the second test, with a totally unparseable JSON object.
How can we build it? Or more precisely, why in the first place this request:
```node
newWish = '{"name": "New Keyboard", "link": "http://www.thekeyboardwaffleiron.com/"';
apiResponse = chakram.post(
"http://127.0.0.1:5000/wishes",
newWish
);
```
was transformed in:
```raw
Full request:
Request data: b'"{\\"name\\": \\"New Keyboard\\", \\"link\\": \\"http://www.thekeyboardwaffleiron.com/\\""'
```
Let's take a look at the chakram documentation:
```
staticmodule:chakram.post(url, data, params){Promise}
methods.js, line 116
Perform HTTP POST request
Name Type Description
url string fully qualified url
data Object a JSON serializable object (unless json is set to false in params, in which case this should be a buffer or string)
params Object optional additional request options, see the popular request library for options
```
Hmm, I guess that "JSON serializable" means it will serialize it into valid
json, hence the addition of double quotes an