Quantcast
Channel: Planet Python
Viewing all articles
Browse latest Browse all 22462

Patrick Kennedy: Receiving Files with a Flask REST API

$
0
0

Introduction

Over the past two months, I’ve spent a lot of time learning about designing and implementing REST APIs. It’s been a lot of fun learning what a REST API is and I really enjoyed learning how to implement a REST API from scratch. While I thought about writing a series of blog posts about what I’ve learning, I think there are two excellent resources already available:

After using these guides from Miguel Grinberg, I decided to implement a REST API for one of my websites that allows you to store recipes (including an image of the recipe). The one area that I struggled with was receiving a file via the API, so I wanted to document how I designed, implemented, and tested this feature of the API.

The source code for this project can be found on my GitLab page: https://gitlab.com/patkennedy79/flask_recipe_app

Design

The concept of sending a file and the associated metadata to a REST API has many design options, as outlined on the following Stack Overflow discussion: Posting a File and Associated Data to a RESTful Webservice

After researching the choices available, I really liked the following sequence:

  1. Client creates a new recipe (via a POST)
  2. Server returns a URL to the new resource
  3. Client sends the image (via PUT)

This sequence feels different than the process used via a web form, where all the data and the image are sent together as a ‘multipart/form-data’ POST. However, the concept of creating a new recipe (via POST) and then following up with the upload of the image to a specific URL seems like a clean and straight-forward implementation for the API.

Implementation

One of my biggest takeaways from Miguel Grinberg’s Building Web APIs with Flask (video course) was the concept of minimizing the amount of logic/code in your routes (views.py) and pushing the complexity to your database interface (models.py). The idea behind this is to keep your routes as simple to understand as possible to improve maintainability. I really like this philosophy and I’ll be utilizing it during this implementation.

The first change for being able to process files is to update the import_data() method in the Recipe model (…/web/project/models.py). Previously, this method would import JSON data associated with a recipe, but now this method needs to be able to also import an image (lines added are highlighted):

    def import_data(self, request):
        """Import the data for this recipe by either saving the image associated
        with this recipe or saving the metadata associated with the recipe. If
        the metadata is being processed, the title and description of the recipe
        must always be specified."""
        try:
            if 'recipe_image' in request.files:
                filename = images.save(request.files['recipe_image'])
                self.image_filename = filename
                self.image_url = images.url(filename)
            else:
                json_data = request.get_json()
                self.recipe_title = json_data['title']
                self.recipe_description = json_data['description']
                if 'recipe_type' in json_data:
                    self.recipe_type = json_data['recipe_type']
                if 'rating' in json_data:
                    self.rating = json_data['rating']
                if 'ingredients' in json_data:
                    self.ingredients = json_data['ingredients']
                if 'recipe_steps' in json_data:
                    self.recipe_steps = json_data['recipe_steps']
                if 'inspiration' in json_data:
                    self.inspiration = json_data['inspiration']
        except KeyError as e:
            raise ValidationError('Invalid recipe: missing ' + e.args[0])
        return self

This method assumes that the image will be defined as ‘recipe_image’ within the request.files dictionary. If this element is defined, then the image is saved and the image filename and URL are extracted.

The remainder of the method is unchanged for processing the JSON data associated with a recipe.

The route that uses the import_data() function is the api1_2_update_recipe() function (defined in …/web/project/recipes_api/views.py):

@recipes_api_blueprint.route('/api/v1_2/recipes/<int:recipe_id>', methods=['PUT'])
def api1_2_update_recipe(recipe_id):
    recipe = Recipe.query.get_or_404(recipe_id)
    recipe.import_data(request)
    db.session.add(recipe)
    db.session.commit()
    return jsonify({'result': 'True'})

The simplicity of this function is amazing. All of the error handling is handled elsewhere, so you’re just left with what the true purpose of the function is: save data to an existing recipe in the database.

Since the import_data() method associated with the Recipe model was changed, the function for creating a new recipe via the API needs to be updated as well to pass in the request instead of the JSON data extracted from the request:

@recipes_api_blueprint.route('/api/v1_2/recipes', methods=['POST'])
def api1_2_create_recipe():
    new_recipe = Recipe()
    new_recipe.import_data(request)
    db.session.add(new_recipe)
    db.session.commit()
    return jsonify({}), 201, {'Location': new_recipe.get_url()}

Testing

There is already a test suite for the Recipes API Blueprint, so expanding these tests is the best approach for testing the receipt of images. The tests are defined in …/web/project/tests/ and each test suite has a filename that starts with ‘test_*’ to allow it to be discoverable by Nose2.

Here is the test case for sending a file:

    def test_recipes_api_sending_file(self):
        headers = self.get_headers_authenticated_admin()
        with open(os.path.join('project', 'tests', 'IMG_6127.JPG'), 'rb') as fp:
            file = FileStorage(fp)
            response = self.app.put('/api/v1_2/recipes/2', data={'recipe_image': file}, headers=headers,
                                    content_type='multipart/form-data', follow_redirects=True)
            json_data = json.loads(response.data.decode('utf-8'))

            self.assertEqual(response.status_code, 200)
            self.assertIn('True', json_data['result'])

This test utilizes an image (IMG_6127.JPG) that I’ve copied to the same folder as the test suites (…/web/project/tests/). The first call to get_headers_authenticated_admin() is defined in the Helper Function section of this test suite as it is utilized by multiple test cases. Next, the image file is opened utilizing a context manager to ensure the proper cleanup. The PUT call to ‘/api/v1_2/recipes/2’ includes the header with the authentication keys, the content type of ‘multipart/form-data’, and the actual image defined in the data dictionary. Note how the file is associated with ‘recipe_image’ in the data dictionary to ensure that it is processed properly by the API.

The response from the PUT call is then checked to make sure the result was successful and the status code returned is 200, as expected.

Common Issue Encountered

I ran into a lot of problems trying to get this unit test to work properly. One error message that I got numerous time was along the lines of:

requests.exceptions.ConnectionError: ('Connection aborted.', BrokenPipeError(32, 'Broken pipe’))

This means that the server is not processing the file being sent to it by the client.

This error message feels a bit misleading, as it typically indicates that the server did not properly process the file that was attempted to be sent by the client (could be a unit test case). This should be seen as an error with saving the file on the server side, so double-check the following section in …/web/project/models.py:

                filename = images.save(request.files['recipe_image'])
                self.image_filename = filename
                self.image_url = images.url(filename)

Client Application

Getting the right combination of inputs for the unit test was a frustrating exercise, but necessary to develop a test case for this new functionality. Luckily, developing a simple client application with the Requests module from Kenneth Reitz is a much more enjoyable process.

Here’s a simple application to test out some of the functionality of the Recipes API:

import requests


URL_BASE = 'http://localhost:5000/'
auth = (‘EMAIL_ADDRESS', 'PASSWORD')

# API v1.2 - Get Authentication Token
print('Retrieving authentication token...')
url = URL_BASE + 'get-auth-token'
r = requests.get(url, auth=auth)
print(r.status_code)
print(r.headers)
auth_request = r.json()
token_auth = (auth_request['token'], 'unused')

# API v1.2 - GET (All)
print('Retrieving all recipes...')
url = URL_BASE + 'api/v1_2/recipes'
r = requests.get(url, auth=token_auth)
print(r.status_code)
print(r.text)

# API v1.2 - PUT (Metadata)
print('Updating recipe #2...')
url = URL_BASE + 'api/v1_2/recipes/2'
json_data = {'title': 'Updated recipe', 'description': 'My favorite recipe'}
r = requests.put(url, json=json_data, auth=token_auth)
print(r.status_code)
print(r.text)

# API v1.2 - PUT (Add image)
print('Updating recipe #2 with recipe image...')
url = URL_BASE + 'api/v1_2/recipes/2'
r = requests.put(url, auth=token_auth, files={'recipe_image': open('IMG_6127.JPG', 'rb')})
print(r.status_code)
print(r.text)

Utilizing the Requests module to write this simple client application was a joy, especially compared to getting the test case to work. The process of sending a file in a PUT call requires a single line of code (see highlighted line).

In order to run this client application, you need to have the application running with the development server in a separate terminal window:

(ffr_env) $ python run.py 
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

In another window, run the client:

$ python testing_api.py

You’ll see lots of text go flying by, but take a look at the output from the Flask development server:

127.0.0.1 - - [23/Jan/2017 22:16:22] "GET /get-auth-token HTTP/1.1" 200 -
127.0.0.1 - - [23/Jan/2017 22:16:22] "GET /api/v1_2/recipes HTTP/1.1" 200 -
127.0.0.1 - - [23/Jan/2017 22:16:22] "PUT /api/v1_2/recipes/2 HTTP/1.1" 200 -
127.0.0.1 - - [23/Jan/2017 22:16:23] "PUT /api/v1_2/recipes/2 HTTP/1.1" 200 -

This shows that each request to the server was handled successfully (as seen by 200 status code sent back for each request). The JSON data return from each request also confirms the proper response from the server.

Conclusion

I’ve thoroughly enjoyed learning about how to design, implement, and test a REST API in Flask. Having a REST API for your web application is a great addition. My recommendation is to develop your own REST API from scratch, especially if you are new to REST or developing APIs. Later, you may want to consider a module (such as Flask-RESTFul) for assisting you in developing a REST API.

The materials from Miguel Grinberg are excellent and I highly recommend them to anyone interested in REST APIs for Flask. This blog post was intended to supplement that material by describing how to handle receiving an image with a REST API.

For the complete source code discussed in this blog post, please see my GitLab page: https://gitlab.com/patkennedy79/flask_recipe_app


Viewing all articles
Browse latest Browse all 22462

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>