├── .gitignore ├── LICENSE ├── README.md ├── requirements.txt ├── rest-server-v2.py ├── rest-server.py ├── setup.bat ├── setup.sh └── static └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Miguel Grinberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | REST-tutorial 2 | ============= 3 | 4 | Files for my REST API tutorials featuring a server written in Python and a web client written in Javascript. Here are the articles: 5 | 6 | - [Designing a RESTful API with Python and Flask](http://blog.miguelgrinberg.com/post/designing-a-restful-api-with-python-and-flask) 7 | - [Writing a Javascript REST client](http://blog.miguelgrinberg.com/post/writing-a-javascript-rest-client) 8 | - [Designing a RESTful API using Flask-RESTful](http://blog.miguelgrinberg.com/post/designing-a-restful-api-using-flask-restful) 9 | 10 | Setup 11 | ----- 12 | 13 | - Install Python 3 and git. 14 | - Run `setup.sh` (Linux, OS X, Cygwin) or `setup.bat` (Windows) 15 | - Run `./rest-server.py` to start the server (on Windows use `flask\Scripts\python rest-server.py` instead) 16 | - Alternatively, run `./rest-server-v2.py` to start the Flask-RESTful version of the server. 17 | - Open `http://localhost:5000/index.html` on your web browser to run the client 18 | 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aniso8601==9.0.1 2 | click==8.0.3 3 | Flask==2.0.2 4 | Flask-HTTPAuth==4.5.0 5 | Flask-RESTful==0.3.9 6 | itsdangerous==2.0.1 7 | Jinja2==3.0.3 8 | MarkupSafe==2.0.1 9 | pytz==2021.3 10 | six==1.16.0 11 | Werkzeug==2.2.3 12 | -------------------------------------------------------------------------------- /rest-server-v2.py: -------------------------------------------------------------------------------- 1 | #!flask/bin/python 2 | 3 | """Alternative version of the ToDo RESTful server implemented using the 4 | Flask-RESTful extension.""" 5 | 6 | from flask import Flask, jsonify, abort, make_response 7 | from flask_restful import Api, Resource, reqparse, fields, marshal 8 | from flask_httpauth import HTTPBasicAuth 9 | 10 | app = Flask(__name__, static_url_path="") 11 | api = Api(app) 12 | auth = HTTPBasicAuth() 13 | 14 | 15 | @auth.get_password 16 | def get_password(username): 17 | if username == 'miguel': 18 | return 'python' 19 | return None 20 | 21 | 22 | @auth.error_handler 23 | def unauthorized(): 24 | # return 403 instead of 401 to prevent browsers from displaying the default 25 | # auth dialog 26 | return make_response(jsonify({'message': 'Unauthorized access'}), 403) 27 | 28 | tasks = [ 29 | { 30 | 'id': 1, 31 | 'title': u'Buy groceries', 32 | 'description': u'Milk, Cheese, Pizza, Fruit, Tylenol', 33 | 'done': False 34 | }, 35 | { 36 | 'id': 2, 37 | 'title': u'Learn Python', 38 | 'description': u'Need to find a good Python tutorial on the web', 39 | 'done': False 40 | } 41 | ] 42 | 43 | task_fields = { 44 | 'title': fields.String, 45 | 'description': fields.String, 46 | 'done': fields.Boolean, 47 | 'uri': fields.Url('task') 48 | } 49 | 50 | 51 | class TaskListAPI(Resource): 52 | decorators = [auth.login_required] 53 | 54 | def __init__(self): 55 | self.reqparse = reqparse.RequestParser() 56 | self.reqparse.add_argument('title', type=str, required=True, 57 | help='No task title provided', 58 | location='json') 59 | self.reqparse.add_argument('description', type=str, default="", 60 | location='json') 61 | super(TaskListAPI, self).__init__() 62 | 63 | def get(self): 64 | return {'tasks': [marshal(task, task_fields) for task in tasks]} 65 | 66 | def post(self): 67 | args = self.reqparse.parse_args() 68 | task = { 69 | 'id': tasks[-1]['id'] + 1 if len(tasks) > 0 else 1, 70 | 'title': args['title'], 71 | 'description': args['description'], 72 | 'done': False 73 | } 74 | tasks.append(task) 75 | return {'task': marshal(task, task_fields)}, 201 76 | 77 | 78 | class TaskAPI(Resource): 79 | decorators = [auth.login_required] 80 | 81 | def __init__(self): 82 | self.reqparse = reqparse.RequestParser() 83 | self.reqparse.add_argument('title', type=str, location='json') 84 | self.reqparse.add_argument('description', type=str, location='json') 85 | self.reqparse.add_argument('done', type=bool, location='json') 86 | super(TaskAPI, self).__init__() 87 | 88 | def get(self, id): 89 | task = [task for task in tasks if task['id'] == id] 90 | if len(task) == 0: 91 | abort(404) 92 | return {'task': marshal(task[0], task_fields)} 93 | 94 | def put(self, id): 95 | task = [task for task in tasks if task['id'] == id] 96 | if len(task) == 0: 97 | abort(404) 98 | task = task[0] 99 | args = self.reqparse.parse_args() 100 | for k, v in args.items(): 101 | if v is not None: 102 | task[k] = v 103 | return {'task': marshal(task, task_fields)} 104 | 105 | def delete(self, id): 106 | task = [task for task in tasks if task['id'] == id] 107 | if len(task) == 0: 108 | abort(404) 109 | tasks.remove(task[0]) 110 | return {'result': True} 111 | 112 | 113 | api.add_resource(TaskListAPI, '/todo/api/v1.0/tasks', endpoint='tasks') 114 | api.add_resource(TaskAPI, '/todo/api/v1.0/tasks/', endpoint='task') 115 | 116 | 117 | if __name__ == '__main__': 118 | app.run(debug=True) 119 | -------------------------------------------------------------------------------- /rest-server.py: -------------------------------------------------------------------------------- 1 | #!flask/bin/python 2 | from flask import Flask, jsonify, abort, request, make_response, url_for 3 | from flask_httpauth import HTTPBasicAuth 4 | 5 | app = Flask(__name__, static_url_path="") 6 | auth = HTTPBasicAuth() 7 | 8 | 9 | @auth.get_password 10 | def get_password(username): 11 | if username == 'miguel': 12 | return 'python' 13 | return None 14 | 15 | 16 | @auth.error_handler 17 | def unauthorized(): 18 | # return 403 instead of 401 to prevent browsers from displaying the default 19 | # auth dialog 20 | return make_response(jsonify({'error': 'Unauthorized access'}), 403) 21 | 22 | 23 | @app.errorhandler(400) 24 | def bad_request(error): 25 | return make_response(jsonify({'error': 'Bad request'}), 400) 26 | 27 | 28 | @app.errorhandler(404) 29 | def not_found(error): 30 | return make_response(jsonify({'error': 'Not found'}), 404) 31 | 32 | 33 | tasks = [ 34 | { 35 | 'id': 1, 36 | 'title': u'Buy groceries', 37 | 'description': u'Milk, Cheese, Pizza, Fruit, Tylenol', 38 | 'done': False 39 | }, 40 | { 41 | 'id': 2, 42 | 'title': u'Learn Python', 43 | 'description': u'Need to find a good Python tutorial on the web', 44 | 'done': False 45 | } 46 | ] 47 | 48 | 49 | def make_public_task(task): 50 | new_task = {} 51 | for field in task: 52 | if field == 'id': 53 | new_task['uri'] = url_for('get_task', task_id=task['id'], 54 | _external=True) 55 | else: 56 | new_task[field] = task[field] 57 | return new_task 58 | 59 | 60 | @app.route('/todo/api/v1.0/tasks', methods=['GET']) 61 | @auth.login_required 62 | def get_tasks(): 63 | return jsonify({'tasks': [make_public_task(task) for task in tasks]}) 64 | 65 | 66 | @app.route('/todo/api/v1.0/tasks/', methods=['GET']) 67 | @auth.login_required 68 | def get_task(task_id): 69 | task = [task for task in tasks if task['id'] == task_id] 70 | if len(task) == 0: 71 | abort(404) 72 | return jsonify({'task': make_public_task(task[0])}) 73 | 74 | 75 | @app.route('/todo/api/v1.0/tasks', methods=['POST']) 76 | @auth.login_required 77 | def create_task(): 78 | if not request.json or 'title' not in request.json: 79 | abort(400) 80 | task = { 81 | 'id': tasks[-1]['id'] + 1 if len(tasks) > 0 else 1, 82 | 'title': request.json['title'], 83 | 'description': request.json.get('description', ""), 84 | 'done': False 85 | } 86 | tasks.append(task) 87 | return jsonify({'task': make_public_task(task)}), 201 88 | 89 | 90 | @app.route('/todo/api/v1.0/tasks/', methods=['PUT']) 91 | @auth.login_required 92 | def update_task(task_id): 93 | task = [task for task in tasks if task['id'] == task_id] 94 | if len(task) == 0: 95 | abort(404) 96 | if not request.json: 97 | abort(400) 98 | if 'title' in request.json and \ 99 | not isinstance(request.json['title'], str): 100 | abort(400) 101 | if 'description' in request.json and \ 102 | not isinstance(request.json['description'], str): 103 | abort(400) 104 | if 'done' in request.json and type(request.json['done']) is not bool: 105 | abort(400) 106 | task[0]['title'] = request.json.get('title', task[0]['title']) 107 | task[0]['description'] = request.json.get('description', 108 | task[0]['description']) 109 | task[0]['done'] = request.json.get('done', task[0]['done']) 110 | return jsonify({'task': make_public_task(task[0])}) 111 | 112 | 113 | @app.route('/todo/api/v1.0/tasks/', methods=['DELETE']) 114 | @auth.login_required 115 | def delete_task(task_id): 116 | task = [task for task in tasks if task['id'] == task_id] 117 | if len(task) == 0: 118 | abort(404) 119 | tasks.remove(task[0]) 120 | return jsonify({'result': True}) 121 | 122 | 123 | if __name__ == '__main__': 124 | app.run(debug=True) 125 | -------------------------------------------------------------------------------- /setup.bat: -------------------------------------------------------------------------------- 1 | python -m venv venv 2 | venv\Scripts\pip install -r requirements.txt 3 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | python -m venv venv 2 | venv/bin/pip install -r requirements.txt 3 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ToDo API Client Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 |
18 | 19 | 20 | 21 | 22 | 26 | 27 | 37 | 38 | 39 |
TaskOptions
23 | Done 24 | In Progress 25 |

28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
40 | 41 |
42 | 68 | 101 | 125 | 278 | 279 | 280 | --------------------------------------------------------------------------------