diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b60e39b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +.vscode/ +venv/ +__pycache__ +.DS_Store/ +migrations/ diff --git a/README.md b/README.md index 7a0e42d..93778c5 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,245 @@ -# Flask Take Home Exercises +# Flask Exercise +This exercise is intended for you to get familiar with fundamental backend/server side programming in an interactive way, as well as for you to get comfortable developing in a modern Python/Flask environment. -This creates an application instance -__name__ is used to determine the root path of the application folder +Reading the following will help you get a sense of the big picture when it comes to developing APIs/writing server side code, and how it fits in the context of a larger web application: +* [How the Web Works](https://medium.freecodecamp.org/how-the-web-works-a-primer-for-newcomers-to-web-development-or-anyone-really-b4584e63585c) - Read all 3 parts, especially part 3! +* [Basics of HTTP](https://egghead.io/courses/understand-the-basics-of-http) -```python -from flask import Flask -app = Flask(__name__) +This project will be broken down into multiple parts. After you finish this project, submit a pull request and assign your tech lead to review it! + +This exercise is due before the next all hands meeting (Sunday Feb 18th). However, the sooner you put up your PR, the sooner you will get a review, and the faster you will get feedback and learn! + +### Guidance +We understand that a lot of you are new to Flask and backend development in general. We think going through this exercise will really help you get up to speed in order to start being productive contributing to your nonprofit project. + +A lot of what makes a good software developer is being resourceful and knowing where/how to find information you need. At the same time, the entire Hack4Impact community is available if you get stuck, have unanswered questions, or want to discuss anything! + +Ask questions and discuss about python as a language and its features or syntax in the `#python` Slack channel. + +Ask questions and discuss about Flask, creating endpoints, or any other backend related topics in the `#backend` Slack channel. + +And of course, if you are already familiar with this or have figured it out, please hop in these channels and help those who need it! :) + +### Requirements +* python version 3.x +* pip +* [Postman](https://www.getpostman.com/) + +Installation instructions for [Mac](https://github.com/hack4impact-uiuc/wiki/wiki/Mac-Setup) and [Windows](https://github.com/hack4impact-uiuc/wiki/wiki/Windows-Subsystem-for-Linux-Setup#setting-up-python) + +Check if you have the correct versions by running the following commands in your terminal: +``` +python3 -V +``` +``` +pip3 -V +``` + +### Setup +First, clone this repository and go into it: +``` +$ git clone https://github.com/hack4impact-uiuc/backend-exercise.git +$ cd backend-exercise +``` + +Then, install `virtualenv` and run it. This allows you to have a virtual environment for your specific application. +``` +$ pip3 install virtualenv +$ virtualenv -p python3 venv +$ source venv/bin/activate +``` + +You will then have a `(venv)` before the `$`, meaning that you are now in your virtual environment. Then, install Flask. +``` +(venv)$ pip3 install Flask +(venv)$ pip3 install requests +``` + +You must be in this virtual environment to start this server. To start the server run: +``` +(venv)$ python app.py +``` + +Note: This will remain a running process in your terminal, so you will need to open a new tab or window to execute other commands. + +To stop the server, press `Control-C`. + +To exit your virtual environment, which is named `venv`, run: +``` +(venv)$ deactivate venv +``` + +Before you make any changes to the code, make sure to create a new branch. Typically branches are named based on the feature or bugfix being addressed, but for this project, name your branch with your own name so your reviewer can easily follow: ``` +git checkout -b +``` +Branch names should be all lowercase and can't contain spaces. Instead of spaces, use hyphens. For example: +``` +git checkout -b varun-munjeti +``` + +### Running The Server And Calling Endpoints +Starting the server will make it a continuously running process on `localhost:5000`. In order to make requests to your server, use [Postman](https://www.getpostman.com/). + +First, make a `GET` request to the `/` endpoint. Since the server is running on `localhost:5000`, the full endpoint url is `localhost:5000/`. + +![Postman GET](docs/postman_get.png) + +Try calling the `/mirror` endpoint. First, look at the code for the endpoint to see how you can specify url parameters. Then make a request on Postman to `localhost:5000/mirror/`: + +![Postman GET mirror](docs/postman_get_mirror.png) -Running the Server +# Exercises +These exercises will walk you through creating a RESTful API using Flask! We don't want you to go through all the hassle of setting up a database instance, so we have created dummy data and a mock database interface to interact with it. For the sake of ease, the entire app logic minus the mockdb logic will by implemented in `app.py`. For larger projects, the API endpoints will usually be separated out into different files called `views`. -```python -if __name__ == '__main__': - app.run(debug=True) +Before you start, take a good look at the `create_response` function and how it works. Make sure you follow the guidelines for how to use this function, otherwise your API will not follow the proper conventions! + +Also take a look into the mock database. The initial dummy data is defined in `mockdb/dummy_data.py`. This is what will "exist" in the "database" when you start the server. + +The functions defined in `mockdb/mockdb_interface.py` are how you can query the mockdb. In `app.py`, where you will be writing your API, this has been imported with the name `db`. Therefore when you write the code for your endpoints, you can call the db interface functions like `db.get('users')`. + +When you modify your code, the server will automatically update, *unless* your code doesn't compile, in which case the server will stop running and you have to manually restart it after fixing your code. + +## Part 1 +Define the endpoint: +``` +GET /users ``` -App routes define the path to different pages in your application. This means that when you run python app.py, on http://127.0.0.1:5000/ it will run the function my_first_route that will print hello world. -```python -@app.route('/') -def my_first_route(): - return "

Hello World!

" +This should return a properly formatted JSON response that contains a list of all the `user`s in the mockdb. If you call this endpoint immediately after starting the server, you should get this response in Postman: +``` +{ + "code": 200, + "message": "", + "result": { + "users": [ + { + "age": 19, + "id": 1, + "name": "Aria", + "team": "LWB" + }, + { + "age": 20, + "id": 2, + "name": "Tim", + "team": "LWB" + }, + { + "age": 23, + "id": 3, + "name": "Varun", + "team": "NNB" + }, + { + "age": 24, + "id": 4, + "name": "Alex", + "team": "C2TC" + } + ] + }, + "success": true +} ``` -You can have multiple routes. So for instance, the code below would run on http://127.0.0.1:5000/route/aria, -"aria" in this case is the parameter name, which can be anything you want. This is taken as an a input into the function my_second_route, and then the name is printed to the screen. -```python -@app.route('/route/') -def my_second_route(name): - return name +## Part 2 +Define the endpoint: +``` +GET /users/ ``` -**Problem 1** -**Write a route that takes a first name, last name, and graduating year in the route. If this route is hit, wit print out the line " will graduate in "** +This should retrieve a single user that has the `id` provided from the request. +If there doesn't exist a user with the provided `id`, return a `404` with a descriptive `message`. -So, what are we using Flask for and why are routes useful ... to build at API. +## Part 3 +Extend the first `/users` enpoint by adding the ability to query the users based on the team they are on. You should *not* use a url parameter like you did in Part 2. Instead, use a [query string](https://en.wikipedia.org/wiki/Query_string). -An API, or Application Programming Interface, is a set of subroutine definitions, protocols, and tools for building application software. It defines communication between various software components. +If `team` is provided as a query string parameter, only return the users that are in that team. If there are no users on the provided `team`, return an empty list. -Lets give an example. Let's say on the frontend you want to display all a list of names that are stored in your database. You are going to send a GET request that will be sent to one of these routes that you define in Flask. The function to handle the route will understand that you are trying to get some information, retrieve it from the database, and set in back to the frontend in a json format. +For this exercise, you can ignore any query string parameters other than `team`. +In Postman, you can supply query string parameters writing the query string into your request url or by hitting the `Params` button next to `Send`. Doing so will automatically fill in the request url. -The GET request is part of a protocol called REST, which stands for Representational State Transfer. +![Postman Query String Request](docs/postman_querystring.png) -There are many types of requests, put the most important ones are: +## Part 4 +Define the endpoint: +``` +POST /users +``` -GET: gets information from a database +This endpoint should create a new user. Each request should also send a `name`, `age`, and `team` parameter in the request's `body`. The `id` property will be created automatically in the mockdb. -POST: adds information to a database +A successful request should return a status code of `201` and return the newly created user. -PUT: modifies information in a database +If any of the three required parameters aren't provided, DO NOT create a new user in the db and return a `422` with a useful `message`. In general, your messages should provide the user/developer useful feedback on what they did wrong and how they can fix it. -DELETE: deletes information in a database +This is how you can send `body` parameters from Postman. Make sure you don't mistake this for query parameters! +![Postman POST](docs/postman_post.png) -From the nnb project from last semester, you can see an example of a get request that uses postgress database. Maps.query.all() goes into postgress, finds the table labeled Maps, and gets everything. The data is then put into a list and turned into a json object. If it fails, it will send the correct error message -```python -#Gets all maps -@app.route('/maps', methods=['GET']) -def getallyears(): - if request.method == 'GET': - try: - print(len(Maps.query.all())) - return jsonify({'status': 'success', 'data': serializeList((Maps.query.all()))}) - except Exception as ex: - raise InvalidUsage('Error: ' + str(ex), status_code=404) - else: - return jsonify({"status": "failed", "message": "Endpoint, /years, needs a GET request"}) +## Part 5 +Define the endpoint: +``` +PUT /users/ ``` -Here's a POST request example from the same project -```python -#Add a map -@app.route('/maps', methods=['POST']) -# @login_required -def addmapforyear(): - if request.method == 'POST': - try: - json_dict = json.loads(request.data) - result = Maps( - image_url = json_dict['image_url'], - year = (int)(json_dict['year']) - ) - db.session.add(result) - db.session.commit() - return jsonify({"status": "success", "message": "successfully added maps and year"}) - except Exception as ex: - raise InvalidUsage('Error: ' + str(ex), status_code=404) - else: - return jsonify({"status": "failed", "message": "Endpoint, /maps, needs a GET or POST request"}) +Here we need to provide a user's `id` since we need to specify which user to update. The `body` for this request should contain the same attributes as the `POST` request from Part 4. + +However, the difference with this `PUT` request is that only values with the provided keys (`name`, `age`, `team`) will be updated, and any parameters not provided will not change the corresponding attribute in the user being updated. + +You do not need to account for `body` parameters provided that aren't `name`, `age`, or `team`. + +If the user with the provided `id` cannot be found, return a `404` and a useful `message`. + +## Part 6 +Define the endpoint: +``` +DELETE /users/ ``` -Everything that I described above is what you're going to be working on in the Flask backend. This means figuring out how to design your database, and then define the API to make changes in your database. +This will delete the user with the associated `id`. Return a useful `message`, although nothing needs to be specified in the response's `result`. +If the user with the provided `id` cannot be found, return a `404` and a useful `message`. +## Submitting +When you're done with all the steps, open a pull request (PR) and assign your tech lead to review it! -**Problem 2:** +Before you can submit a PR, you'll have to push your branch to a remote branch (the one that's on GitHub, not local). -**So instead of making you guys actually use a database, simply make an array called *users* thats global in your app.py file. Each element in the array is a user with a id, name, and age** +Check to see that you're on your branch: +``` +git branch +``` -For example -```json +If you want to make sure all of your commits are in: +``` +git log +``` +Press `Q` to quit the `git log` screen. - users = [ - { - "id": 1, - "name": "Aria", - "age": 19 - }, - { - "id": 2, - "name": "Tim", - "age": 20 - }, - { - "id": 1, - "name": "Varun", - "age": 23 - }, - { - "id": 1, - "name": "Alex", - "age": 24 - } - ] +Push your commits to your remote branch: +``` +git push +``` + +The first time you do this, you might get an error since your remote branch doesn't exist yet. Usually it will tell you the correct command to use: +``` +git push --set-upstream origin ``` +Note: this only needs to be done the first time you push a new branch. You can use just `git push` afterwards. -**Then create a route for /get_all_users that will receive a GET request and return the list of all current users in a json format. It will return an error message for everything other than a GET request.** +Follow the instructions on the [wiki](https://github.com/hack4impact-uiuc/wiki/wiki/Git-Reference-Guide#opening-a-pull-request-pr-on-github) to open the PR. -**Next create a route called /add_user that will receieve a POST request. Inside the request data there will be a user with an id, name, and age. The function will take the request data and add a new user to the globale list of users. Also, add appropriate sucess/error responses in a json format.** +For this specific PR, since this repo is forked, there are a couple extra steps you need to do for your PR. -**Next create a route called /modify_user that will receieve a PUT request. In the request data have an id so they know which user is being modified, and then have a new name or age for the user. In the function, edit the user with that id in the global list of users. Also, add appropriate sucess/error responses in a json format.** +When you create your PR on github, by default it'll want to make a PR for Aria's repo. -**Next create a route called /delete_user that will receieve a DELETE request and a name. The request data will have an id,and then that user is deleted from teh global array. Also, add appropriate sucess/error responses in a json format.** +First, we need to choose H4I's repo as the base: +![PR Base](docs/pr1.png) -**To test everything, download postman and make requests** +Then, choose your branch (using Dean's as an example): +![PR Branch](docs/pr2.png) -Setting up the database and defining it is alot of work, so we'll leave that for your tech leads to teach you. Also, for the course of this intro project, we're doing everything in app.py. In your projects though, you are going to organize the endpoints into different files, have a folder to define the models, and other files for the database connection. +Note: make sure that you don't actually merge your PR in! We are only using it as a mechanism for providing code reviews. diff --git a/app.py b/app.py index 14b8afa..8515261 100644 --- a/app.py +++ b/app.py @@ -1,22 +1,99 @@ -from flask import Flask -from flask import render_template -from flask import jsonify +from flask import Flask, jsonify, request +import mockdb.mockdb_interface as db + app = Flask(__name__) +def create_response(data={}, status=200, message=''): + """ + Wraps response in a consistent format throughout the API + Format inspired by https://medium.com/@shazow/how-i-design-json-api-responses-71900f00f2db + Modifications included: + - make success a boolean since there's only 2 values + - make message a single string since we will only use one message per response + + IMPORTANT: data must be a dictionary where: + - the key is the name of the type of data + - the value is the data itself + """ + response = { + 'success': 200 <= status < 300, + 'code': status, + 'message': message, + 'result': data + } + return jsonify(response), status + +""" +~~~~~~~~~~~~ API ~~~~~~~~~~~~ +""" + @app.route('/') -def my_first_route(): - return "

Hello World!

" - -@app.route('/route/') -def my_second_route(name): - return name - -@app.route('/get_users', methods=['GET']) -def get_all_users(): - if request.method == 'GET': - return jsonify({'status': 'success', 'data': ['aria', 'tim', 'varun', 'alex']}) - else: - return jsonify({"status": "failed") - +def hello_world(): + return create_response('hello world!') + +@app.route('/mirror/') +def mirror(name): + data = { + 'name': name + } + return create_response(data) + +# TODO: Implement the rest of the API here! + +@app.route('/users', methods=['GET']) +def users(): + if request.method == 'GET': + if request.args.get('team') is None: + data = { + 'users':db.get('users') + } + else: + usersOnTeam = [i for i in db.get('users') if i['team'] == request.args.get('team')] + data = { + 'users': usersOnTeam + } + + return create_response(data) + +@app.route('/users', methods=['POST']) +def post_users(): + input = request.get_json() + try: + name, age, team = input["name"], input["age"], input ["team"] + except: + return create_response(None,422,"Missing User Information") + entries = {'name': name, + 'age': age, + 'team': team} + + data = db.create('users',entries) + return create_response(data,status=201) + +@app.route('/users/', methods = ['GET','PUT','DELETE']) +def userId(id): + if request.method == 'GET': + if db.getById('users',int(id) is None): + return create_response(None, 404, "User can't be found") + else: + data = { + 'user': db.getById('users',int(id)) + } + return create_response(data) + elif request.method == 'PUT': + entries = {'name': request.form['name'], 'age': request.form['age'], 'team': request.form['team']} + return create_response(db.updateById('users',id,entries)) + + + elif request.method == 'DELETE': + if db.getById('users',int(id)) is None: + return create_response(None,404,"User not found") + else: + return create_response({},200,"User deleted") + + + +""" +~~~~~~~~~~~~ END API ~~~~~~~~~~~~ +""" if __name__ == '__main__': - app.run(debug=True) + app.run(debug=True) diff --git a/docs/postman_get.png b/docs/postman_get.png new file mode 100644 index 0000000..947b863 Binary files /dev/null and b/docs/postman_get.png differ diff --git a/docs/postman_get_mirror.png b/docs/postman_get_mirror.png new file mode 100644 index 0000000..4d00bcc Binary files /dev/null and b/docs/postman_get_mirror.png differ diff --git a/docs/postman_post.png b/docs/postman_post.png new file mode 100644 index 0000000..6e3b937 Binary files /dev/null and b/docs/postman_post.png differ diff --git a/docs/postman_querystring.png b/docs/postman_querystring.png new file mode 100644 index 0000000..5020867 Binary files /dev/null and b/docs/postman_querystring.png differ diff --git a/docs/pr1.png b/docs/pr1.png new file mode 100644 index 0000000..c130b4f Binary files /dev/null and b/docs/pr1.png differ diff --git a/docs/pr2.png b/docs/pr2.png new file mode 100644 index 0000000..c3f2550 Binary files /dev/null and b/docs/pr2.png differ diff --git a/mockdb/dummy_data.py b/mockdb/dummy_data.py new file mode 100644 index 0000000..f859d14 --- /dev/null +++ b/mockdb/dummy_data.py @@ -0,0 +1,28 @@ +initial_db_state = { + 'users': [ + { + "id": 1, + "name": "Aria", + "age": 19, + "team": "LWB" + }, + { + "id": 2, + "name": "Tim", + "age": 20, + "team": "LWB" + }, + { + "id": 3, + "name": "Varun", + "age": 23, + "team": "NNB" + }, + { + "id": 4, + "name": "Alex", + "age": 24, + "team": "C2TC" + } + ] +} diff --git a/mockdb/mockdb_interface.py b/mockdb/mockdb_interface.py new file mode 100644 index 0000000..ad02d19 --- /dev/null +++ b/mockdb/mockdb_interface.py @@ -0,0 +1,32 @@ +from mockdb.dummy_data import initial_db_state +import json + +db_state = initial_db_state + +def get(type): + return db_state[type] + +def getById(type, id): + queried = [i for i in get(type) if i['id'] == id] + if (len(queried)): + return queried[0] + return None + +def create(type, payload): + last_id = max([i['id'] for i in get(type)]) + new_id = last_id + 1 + payload['id'] = new_id + db_state[type].append(payload) + return payload + +def updateById(type, id, update_values): + item = getById(type, id) + if not item: + return None + for k, v in update_values.items(): + if k is not 'id': + item[k] = v + return item + +def deleteById(type, id): + db_state[type] = [i for i in get(type) if i['id'] != id]