Skip to content

Commit 44d3de9

Browse files
committed
✨ (Flask-Smorest) Add whole section on Flask-Smorest
1 parent 73aa74d commit 44d3de9

File tree

17 files changed

+1016
-131
lines changed

17 files changed

+1016
-131
lines changed
Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,34 @@
1-
# Why use Flask-Smorest
1+
# Why use Flask-Smorest
2+
3+
There are many different REST API libraries for Flask. In a previous version of this course, we used Flask-RESTful. Now, I recommend using [Flask-Smorest](https://github.com/marshmallow-code/flask-smorest).
4+
5+
Over the last few months, I've been trialing the major REST libraries for Flask. I've built REST APIs using Flask-RESTful, Flask-RESTX, and Flask-Smorest.
6+
7+
I was looking to compare the three libraries in a few key areas:
8+
9+
- **Ease of use and getting started**. Many REST APIs are essentially microservices, so being able to whip one up quickly and without having to go through a steep learning curve is definitely interesting.
10+
- **Maintainability and expandability**. Although many start as microservices, sometimes we have to maintain projects for a long time. And sometimes, they grow past what we originally envisioned.
11+
- **Activity in the library itself**. Even if a library is suitable now, if it is not actively maintained and improved, it may not be suitable in the future. We'd like to teach something that you will use for years to come.
12+
- **Documentation and usage of best practice**. The library should help you write better code by having strong documentation and guiding you into following best practice. If possible, it should use existing, actively maintained libraries as dependencies instead of implementing their own versions of them.
13+
- **Developer experience in production projects**. The main point here was: how easy is it to produce API documentation with the library of choice. Hundreds of students have asked me how to integrate Swagger in their APIs, so it would be great if the library we teach gave it to you out of the box.
14+
15+
## Flask-Smorest is the most well-rounded
16+
17+
It ticks all the boxes above:
18+
19+
- If you want, it can be super similar to Flask-RESTful (which is a compliment, really easy to get started!).
20+
- It uses [marshmallow](https://marshmallow.readthedocs.io/en/stable/) for serialization and deserialization, which is a huge plus. Marshmallow is a very actively-maintained library which is very intuitive and unlocks very easy argument validation. Unfortunately Flask-RESTX [doesn't use marshmallow](https://flask-restx.readthedocs.io/en/latest/marshalling.html), though there are [plans to do so](https://github.com/python-restx/flask-restx/issues/59).
21+
- It provides Swagger (with Swagger UI) and other documentations out of the box. It uses the same marshmallow schemas you use for API validation and some simple decorators in your code to generate the documentation.
22+
- The documentation is the weakest point (compared to Flask-RESTX), but with this course we can help you navigate it. The documentation of marshmallow is superb, so that will also help.
23+
24+
## If you took an old version of this course...
25+
26+
Let me tell you about some of the key differences between a project that uses Flask-RESTful and one that uses Flask-Smorest. After reading through these differences, it should be fairly straightforward for you to look at two projects, each using one library, and compare them.
27+
28+
1. Flask-Smorest uses `flask.views.MethodView` classes registered under a `flask_smorest.Blueprint` instead of `flask_restful.Resource` classes.
29+
2. Flask-Smorest uses `flask_smorest.abort` to return error responses instead of manually returning the error JSON and error code.
30+
3. Flask-Smorest projects define marshmallow schemas that represent incoming data (for deserialization and validation) and outgoing data (for serialization). It uses these schemas to automatically validate the data and turn Python objects into JSON.
31+
32+
Throughout this section I'll show you how to implement these 3 points in practice, so if you've already got a REST API that uses Flask-RESTful, you'll find it really easy to migrate.
33+
34+
Of course, you can keep using Flask-RESTful for your existing projects, and only use Flask-Smorest for new projects. That's also an option! Flask-RESTful isn't abandoned or deprecated, so it's still a totally viable option.

docs/docs/05_flask_smorest/02_improvements_on_first_rest_api/README.md

Lines changed: 353 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,356 @@ description: "Let's add a few routes to our first REST API, so it better matches
1010
- [ ] Create `start` folder
1111
- [ ] Create `end` folder
1212
- [ ] Write TL;DR
13-
- [ ] Create per-file diff between `end` and `start` (use "Compare Folders")
13+
- [ ] Create per-file diff between `end` and `start` (use "Compare Folders")
14+
15+
## New files
16+
17+
Let's start off by creating a `requirements.txt` file with all our dependencies:
18+
19+
```txt title="requirements.txt"
20+
flask
21+
flask-smorest
22+
python-dotenv
23+
```
24+
25+
We're adding `flask-smorest` to help us write REST APIs more easily, and generate documentation for us.
26+
27+
We're adding `python-dotenv` so it's easier for us to load environment variables and use the `.flaskenv` file.
28+
29+
Next, let's create the `.flaskenv` file:
30+
31+
```txt title=".flaskenv"
32+
FLASK_APP=app
33+
FLASK_ENV=development
34+
```
35+
36+
If we have the `python-dotenv` library installed, when we run the `flask run` command, Flask will read the variables inside `.flaskenv` and use them to configure the Flask app.
37+
38+
The configuration that we'll do is to define the Flask app file (here, `app.py`). Then we'll also set the Flask environment to `development`, which does a couple things:
39+
40+
- Sets debug mode to true, which makes the app give us better error messages
41+
- Sets the app reloading to true, so the app restarts when we make code changes
42+
43+
We don't want debug mode to be enabled in production (when we deploy our app), but while we're doing development it's definitely a time-saving tool!
44+
45+
## Code improvements
46+
47+
This is the "First REST API" project from Section 3:
48+
49+
```py title="app.py"
50+
import uuid
51+
from flask import Flask, request
52+
53+
app = Flask(__name__)
54+
55+
stores = {}
56+
items = {}
57+
58+
59+
@app.get("/item/<string:item_id>")
60+
def get_item(item_id):
61+
try:
62+
return items[item_id]
63+
except KeyError:
64+
return {"message": "Item not found"}, 404
65+
66+
67+
@app.post("/items")
68+
def create_item():
69+
request_data = request.get_json()
70+
new_item_id = uuid.uuid4().hex
71+
new_item = {
72+
"name": request_data["name"],
73+
"price": request_data["price"],
74+
"store_id": request_data["store_id"],
75+
}
76+
items[new_item_id] = new_item
77+
return new_item
78+
79+
80+
@app.get("/items")
81+
def get_all_items():
82+
return {"items": list(items.value())}
83+
84+
85+
@app.get("/stores/<string:store_id>")
86+
def get_store(store_id):
87+
try:
88+
# Here you might also want to add the items in this store
89+
# We'll do that later on in the course
90+
return stores[store_id]
91+
except KeyError:
92+
return {"message": "Store not found"}, 404
93+
94+
95+
@app.post("/stores")
96+
def create_store():
97+
request_data = request.get_json()
98+
new_store_id = uuid.uuid4().hex
99+
new_store = {"id": new_store_id, "name": request_data["name"]}
100+
stores[new_store_id] = new_store
101+
return new_store, 201
102+
103+
104+
@app.get("/stores")
105+
def get_all_stores():
106+
return {"stores": list(stores.value())}
107+
```
108+
109+
### Creating a database file
110+
111+
First of all, let's move our "database" to another file.
112+
113+
Create a `db.py` file with the following content:
114+
115+
```py title="db.py"
116+
stores = {}
117+
items = {}
118+
```
119+
120+
And delete those corresponding lines from `app.py`.
121+
122+
Then, import the `stores` and `items` variables from `db.py` in `app.py`:
123+
124+
```py title="app.py"
125+
from db import stores, items
126+
```
127+
128+
### Using `flask_smorest.abort` instead of returning errors manually
129+
130+
At the moment in our API we're doing things like these in case of an error:
131+
132+
```py title="app.py"
133+
@app.get("/stores/<string:store_id>")
134+
def get_store(store_id):
135+
try:
136+
# Here you might also want to add the items in this store
137+
# We'll do that later on in the course
138+
return stores[store_id]
139+
except KeyError:
140+
# highlight-start
141+
return {"message": "Store not found"}, 404
142+
# highlight-end
143+
```
144+
145+
A small improvement we can do on this is use the `abort` function from Flask-Smorest, which helps us write these messages and include a bit of extra information too.
146+
147+
Add this import at the top of `app.py`:
148+
149+
```py title="app.py"
150+
from flask_smorest import abort
151+
```
152+
153+
And then let's change our error returns to use `abort`:
154+
155+
```py title="app.py"
156+
@app.get("/item/<string:item_id>")
157+
def get_item(item_id):
158+
try:
159+
return items[item_id]
160+
except KeyError:
161+
# highlight-start
162+
abort(404, message="Item not found.")
163+
# highlight-end
164+
```
165+
166+
And here:
167+
168+
```py title="app.py"
169+
@app.get("/stores/<string:store_id>")
170+
def get_store(store_id):
171+
try:
172+
# Here you might also want to add the items in this store
173+
# We'll do that later on in the course
174+
return stores[store_id]
175+
except KeyError:
176+
# highlight-start
177+
abort(404, message="Store not found.")
178+
# highlight-end
179+
```
180+
181+
### Adding error handling on creating items and stores
182+
183+
At the moment when we create items and stores, we _expect_ there to be certain items in the JSON body of the request.
184+
185+
If those items are missing, the app will return an error 500, which means "Internal Server Error".
186+
187+
Instead of that, it's good practice to return an error 400 and a message telling the client what went wrong.
188+
189+
To do so, let's inspect the body of the request and see if it contains the data we need.
190+
191+
Let's change our `create_item()` function to this:
192+
193+
```py title="app.py"
194+
@app.post("/items")
195+
def create_item():
196+
item_data = request.get_json()
197+
# Here not only we need to validate data exists,
198+
# But also what type of data. Price should be a float,
199+
# for example.
200+
if (
201+
"price" not in item_data
202+
or "store_id" not in item_data
203+
or "name" not in item_data
204+
):
205+
abort(
206+
400,
207+
message="Bad request. Ensure 'price', 'store_id', and 'name' are included in the JSON payload.",
208+
)
209+
for item in items.values():
210+
if (
211+
item_data["name"] == item["name"]
212+
and item_data["store_id"] == item["store_id"]
213+
):
214+
abort(400, message=f"Item already exists.")
215+
216+
item_id = uuid.uuid4().hex
217+
item = {**item_data, "id": item_id}
218+
items[item_id] = item
219+
220+
return item
221+
```
222+
223+
And our `create_store()` function to this:
224+
225+
```py title="app.py"
226+
@app.post("/stores")
227+
def create_store():
228+
store_data = request.get_json()
229+
if "name" not in store_data:
230+
abort(
231+
400,
232+
message="Bad request. Ensure 'name' is included in the JSON payload.",
233+
)
234+
for store in stores.values():
235+
if store_data["name"] == store["name"]:
236+
abort(400, message=f"Store already exists.")
237+
238+
store_id = uuid.uuid4().hex
239+
store = {**store_data, "id": store_id}
240+
stores[store_id] = store
241+
242+
return store
243+
```
244+
245+
## New endpoints
246+
247+
We want to add some endpoints for added functionality:
248+
249+
- `DELETE /items/<string:item_id>` so we can delete items from the database.
250+
- `PUT /items/<string:item_id>` so we can update items.
251+
- `DELETE /stores/<string:store_id>` so we can delete stores.
252+
253+
### Deleting items
254+
255+
This is almost identical to getting items, but we use the `del` keyword to remove the entry from the dictionary.
256+
257+
```py title="app.py"
258+
@app.delete("/items/<string:item_id>")
259+
def delete_item(item_id):
260+
try:
261+
del items[item_id]
262+
return {"message": "Item deleted."}
263+
except KeyError:
264+
abort(404, message="Item not found.")
265+
```
266+
267+
### Updating items
268+
269+
This is almost identical to creating items, but in this API we've decided to not let item updates change the `store_id` of the item. So clients can change item name and price, but not the store that the item belongs to.
270+
271+
This is an API design decision, and you could very well allow clients to update the `store_id` if you want!
272+
273+
```py title="app.py"
274+
@app.put("/items/<string:item_id>")
275+
def update_item(item_id):
276+
item_data = request.get_json()
277+
# There's more validation to do here!
278+
# Like making sure price is a number, and also both items are optional
279+
# You should also prevent keys that aren't 'price' or 'name' to be passed
280+
# Difficult to do with an if statement...
281+
if "price" not in item_data or "name" not in item_data:
282+
abort(
283+
400,
284+
message="Bad request. Ensure 'price', and 'name' are included in the JSON payload.",
285+
)
286+
try:
287+
item = items[item_id]
288+
item |= item_data
289+
290+
return item
291+
except KeyError:
292+
abort(404, message="Item not found.")
293+
```
294+
295+
:::tip Dictionary update operators
296+
The `|=` syntax is a new dictionary operator. You can read more about it [here](https://blog.teclado.com/python-dictionary-merge-update-operators/).
297+
:::
298+
299+
### Deleting stores
300+
301+
This is very similar to deleting items!
302+
303+
```py title="app.py"
304+
@app.delete("/stores/<string:store_id>")
305+
def delete_store(store_id):
306+
try:
307+
del stores[store_id]
308+
return {"message": "Store deleted."}
309+
except KeyError:
310+
abort(404, message="Store not found.")
311+
```
312+
313+
## Reloading the Flask app on Docker when we change the code
314+
315+
Up to now, we've been re-building the Docker image and re-running the container each time we make a code change.
316+
317+
This is a bit of a time sink, and a bit annoying to do! Let's do it so that the Docker container runs the code that we're editing. That way, when we make a change to the code, the Flask app should restart and use the new code.
318+
319+
All we have to do is:
320+
321+
1. Build the Docker image
322+
2. Run the image, but replace the contents of the image's `/app` directory (where the code is) by the contents of our source code folder in the host machine.
323+
324+
So, first build the Docker image:
325+
326+
```
327+
docker build -t flask-smorest-api .
328+
```
329+
330+
Once that's done, the image has an `/app` directory which contains the source code as it was copied from the host machine during the build stage.
331+
332+
So at this point, we _can_ run a container from this image, and it will run the app _as it was when it was built_:
333+
334+
```
335+
docker run -dp 5000:5000 flask-smorest-api
336+
```
337+
338+
This should just work, and you can try it out in the Insomnia REST Client to make sure the endpoints all work.
339+
340+
But like we said earlier, when we make changes to the code we'll have to rebuild and rerun.
341+
342+
So instead, what we can do is run the image, but replace the image's `/app` directory with the host's source code folder.
343+
344+
That will cause the source code to change in the Docker container while it's running. And, since we've ran Flask with debug mode on, the Flask app will automatically restart when the code changes.
345+
346+
To do so, stop the running container (if you have one running), and use this command instead:
347+
348+
```
349+
docker run -dp 5000:5000 -w /app -v "$(pwd):/app" flask-smorest-api
350+
```
351+
352+
- `-dp 5000:5000` - same as before. Run in detached (background) mode and create a port mapping.
353+
- `-w /app` - sets the container's present working directory where the command will run from.
354+
- `-v "$(pwd):/app"` - bind mount (link) the host's present directory to the container's `/app` directory. Note: Docker requires absolute paths for binding mounts, so in this example we use `pwd` for printing the absolute path of the working directory instead of typing it manually.
355+
- `flask-smorest-api` - the image to use.
356+
357+
And with this, your Docker container now is running the code as shown in your IDE. Plus, since Flask is running with debug mode on, the Flask app will restart when you make code changes!
358+
359+
:::info
360+
Using this kind of volume mapping only makes sense _during development_. When you share your Docker image or deploy it, you won't be sharing anything from the host to the container. That's why it's still important to include the original source code in the image when you build it.
361+
:::
362+
363+
Just to recap, here are the two ways we've seen to run your Docker container:
364+
365+
![Diagram showing two ways of running a Docker container from a built image, with and without volume mapping](./assets/build-with-without-volume.png)
195 KB
Loading

0 commit comments

Comments
 (0)