diff --git a/api-integration-in-python/README.md b/api-integration-in-python/README.md new file mode 100644 index 0000000000..216ed0b129 --- /dev/null +++ b/api-integration-in-python/README.md @@ -0,0 +1,75 @@ +# Python and REST APIs: Interacting With Web Services + +This folder provides the code examples for the Real Python tutorial [Python and REST APIs: Interacting With Web Services](https://realpython.com/api-integration-in-python/). + +The examples are grouped into one subfolder per section of the tutorial, because the Flask and FastAPI examples both use a file called `app.py`, and because the tutorial itself advises you to keep each example in its own folder: + +- `consuming-apis/`: the `requests` examples from **REST and Python: Consuming APIs**. The tutorial shows these in the REPL, so here they're runnable scripts that `print()` the results, one script per HTTP method section. +- `flask-api/`: the Flask countries API from **Tools of the Trade → Flask**. +- `django-api/`: the `countryapi` Django project with Django REST framework from **Tools of the Trade → Django REST Framework**. +- `fastapi-api/`: the FastAPI countries API from **Tools of the Trade → FastAPI**. + +## Setup + +Create and activate a virtual environment: + +```console +$ python -m venv venv +$ source venv/bin/activate +``` + +Install the pinned dependencies: + +```console +(venv) $ python -m pip install -r requirements.txt +``` + +The single `requirements.txt` covers all four examples. If you'd rather isolate them, then create one virtual environment per subfolder and install only the packages that example needs. + +## Consuming APIs With `requests` + +Each script sends one kind of request to [JSONPlaceholder](https://jsonplaceholder.typicode.com/) and prints the response, so you need an internet connection to run them: + +```console +(venv) $ cd consuming-apis/ +(venv) $ python get_request.py +{'userId': 1, 'id': 1, 'title': 'delectus aut autem', 'completed': False} +200 +application/json; charset=utf-8 +``` + +The other scripts are `post_request.py`, `put_request.py`, `patch_request.py`, and `delete_request.py`. + +## Flask + +```console +(venv) $ cd flask-api/ +(venv) $ export FLASK_APP=app.py +(venv) $ export FLASK_DEBUG=1 +(venv) $ flask run +``` + +Then request the endpoint at `http://127.0.0.1:5000/countries`. + +## Django REST Framework + +The `django-api/` folder is the `countryapi` project that the tutorial creates with `django-admin startproject countryapi` and `python manage.py startapp countries`. Set up its database and load the fixture before you start the server: + +```console +(venv) $ cd django-api/ +(venv) $ python manage.py migrate +(venv) $ python manage.py loaddata countries.json +Installed 3 object(s) from 1 fixture(s) +(venv) $ python manage.py runserver +``` + +Then request the endpoint at `http://127.0.0.1:8000/countries/`. + +## FastAPI + +```console +(venv) $ cd fastapi-api/ +(venv) $ uvicorn app:app --reload +``` + +Then request the endpoint at `http://127.0.0.1:8000/countries`. diff --git a/api-integration-in-python/consuming-apis/delete_request.py b/api-integration-in-python/consuming-apis/delete_request.py new file mode 100644 index 0000000000..b2bfd22581 --- /dev/null +++ b/api-integration-in-python/consuming-apis/delete_request.py @@ -0,0 +1,12 @@ +"""Send a DELETE request to JSONPlaceholder to remove a to-do. + +From the "DELETE" section of the tutorial. +""" + +import requests + +api_url = "https://jsonplaceholder.typicode.com/todos/10" +response = requests.delete(api_url) +print(response.json()) + +print(response.status_code) diff --git a/api-integration-in-python/consuming-apis/get_request.py b/api-integration-in-python/consuming-apis/get_request.py new file mode 100644 index 0000000000..cc808a7002 --- /dev/null +++ b/api-integration-in-python/consuming-apis/get_request.py @@ -0,0 +1,14 @@ +"""Send a GET request to JSONPlaceholder. + +From the "GET" section of the tutorial. +""" + +import requests + +api_url = "https://jsonplaceholder.typicode.com/todos/1" +response = requests.get(api_url) +print(response.json()) + +# Beyond the JSON data, you can inspect the response itself. +print(response.status_code) +print(response.headers["Content-Type"]) diff --git a/api-integration-in-python/consuming-apis/patch_request.py b/api-integration-in-python/consuming-apis/patch_request.py new file mode 100644 index 0000000000..8c3791ab37 --- /dev/null +++ b/api-integration-in-python/consuming-apis/patch_request.py @@ -0,0 +1,13 @@ +"""Send a PATCH request to JSONPlaceholder to modify one field. + +From the "PATCH" section of the tutorial. +""" + +import requests + +api_url = "https://jsonplaceholder.typicode.com/todos/10" +todo = {"title": "Mow lawn"} +response = requests.patch(api_url, json=todo) +print(response.json()) + +print(response.status_code) diff --git a/api-integration-in-python/consuming-apis/post_request.py b/api-integration-in-python/consuming-apis/post_request.py new file mode 100644 index 0000000000..9ebce7a2ca --- /dev/null +++ b/api-integration-in-python/consuming-apis/post_request.py @@ -0,0 +1,23 @@ +"""Send a POST request to JSONPlaceholder to create a new to-do. + +From the "POST" section of the tutorial. +""" + +import json + +import requests + +api_url = "https://jsonplaceholder.typicode.com/todos" +todo = {"userId": 1, "title": "Buy milk", "completed": False} +response = requests.post(api_url, json=todo) +print(response.json()) + +print(response.status_code) + +# An equivalent version that serializes the JSON and sets the +# Content-Type header manually instead of using the json argument. +headers = {"Content-Type": "application/json"} +response = requests.post(api_url, data=json.dumps(todo), headers=headers) +print(response.json()) + +print(response.status_code) diff --git a/api-integration-in-python/consuming-apis/put_request.py b/api-integration-in-python/consuming-apis/put_request.py new file mode 100644 index 0000000000..c1825ba67e --- /dev/null +++ b/api-integration-in-python/consuming-apis/put_request.py @@ -0,0 +1,16 @@ +"""Send a PUT request to JSONPlaceholder to replace an existing to-do. + +From the "PUT" section of the tutorial. +""" + +import requests + +api_url = "https://jsonplaceholder.typicode.com/todos/10" +response = requests.get(api_url) +print(response.json()) + +todo = {"userId": 1, "title": "Wash car", "completed": True} +response = requests.put(api_url, json=todo) +print(response.json()) + +print(response.status_code) diff --git a/api-integration-in-python/django-api/countries/__init__.py b/api-integration-in-python/django-api/countries/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api-integration-in-python/django-api/countries/admin.py b/api-integration-in-python/django-api/countries/admin.py new file mode 100644 index 0000000000..4fd549025a --- /dev/null +++ b/api-integration-in-python/django-api/countries/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin # noqa: F401 + +# Register your models here. diff --git a/api-integration-in-python/django-api/countries/apps.py b/api-integration-in-python/django-api/countries/apps.py new file mode 100644 index 0000000000..d2a8fde084 --- /dev/null +++ b/api-integration-in-python/django-api/countries/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CountriesConfig(AppConfig): + name = "countries" diff --git a/api-integration-in-python/django-api/countries/fixtures/countries.json b/api-integration-in-python/django-api/countries/fixtures/countries.json new file mode 100644 index 0000000000..28918ed19e --- /dev/null +++ b/api-integration-in-python/django-api/countries/fixtures/countries.json @@ -0,0 +1,29 @@ +[ + { + "model": "countries.country", + "pk": 1, + "fields": { + "name": "Thailand", + "capital": "Bangkok", + "area": 513120 + } + }, + { + "model": "countries.country", + "pk": 2, + "fields": { + "name": "Australia", + "capital": "Canberra", + "area": 7617930 + } + }, + { + "model": "countries.country", + "pk": 3, + "fields": { + "name": "Egypt", + "capital": "Cairo", + "area": 1010408 + } + } +] diff --git a/api-integration-in-python/django-api/countries/migrations/0001_initial.py b/api-integration-in-python/django-api/countries/migrations/0001_initial.py new file mode 100644 index 0000000000..f4cd1ac871 --- /dev/null +++ b/api-integration-in-python/django-api/countries/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 6.1 on 2026-09-16 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Country', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('capital', models.CharField(max_length=100)), + ('area', models.IntegerField(help_text='(in square kilometers)')), + ], + ), + ] diff --git a/api-integration-in-python/django-api/countries/migrations/__init__.py b/api-integration-in-python/django-api/countries/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api-integration-in-python/django-api/countries/models.py b/api-integration-in-python/django-api/countries/models.py new file mode 100644 index 0000000000..2419fa098d --- /dev/null +++ b/api-integration-in-python/django-api/countries/models.py @@ -0,0 +1,7 @@ +from django.db import models + + +class Country(models.Model): + name = models.CharField(max_length=100) + capital = models.CharField(max_length=100) + area = models.IntegerField(help_text="(in square kilometers)") diff --git a/api-integration-in-python/django-api/countries/serializers.py b/api-integration-in-python/django-api/countries/serializers.py new file mode 100644 index 0000000000..e08c04364d --- /dev/null +++ b/api-integration-in-python/django-api/countries/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from .models import Country + + +class CountrySerializer(serializers.ModelSerializer): + class Meta: + model = Country + fields = ["id", "name", "capital", "area"] diff --git a/api-integration-in-python/django-api/countries/tests.py b/api-integration-in-python/django-api/countries/tests.py new file mode 100644 index 0000000000..e55d689097 --- /dev/null +++ b/api-integration-in-python/django-api/countries/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase # noqa: F401 + +# Create your tests here. diff --git a/api-integration-in-python/django-api/countries/urls.py b/api-integration-in-python/django-api/countries/urls.py new file mode 100644 index 0000000000..b590c8151f --- /dev/null +++ b/api-integration-in-python/django-api/countries/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import CountryViewSet + +router = DefaultRouter() +router.register(r"countries", CountryViewSet) + +urlpatterns = [path("", include(router.urls))] diff --git a/api-integration-in-python/django-api/countries/views.py b/api-integration-in-python/django-api/countries/views.py new file mode 100644 index 0000000000..541fcae2eb --- /dev/null +++ b/api-integration-in-python/django-api/countries/views.py @@ -0,0 +1,9 @@ +from rest_framework import viewsets + +from .models import Country +from .serializers import CountrySerializer + + +class CountryViewSet(viewsets.ModelViewSet): + serializer_class = CountrySerializer + queryset = Country.objects.all() diff --git a/api-integration-in-python/django-api/countryapi/__init__.py b/api-integration-in-python/django-api/countryapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api-integration-in-python/django-api/countryapi/asgi.py b/api-integration-in-python/django-api/countryapi/asgi.py new file mode 100644 index 0000000000..28ff44a5ef --- /dev/null +++ b/api-integration-in-python/django-api/countryapi/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for countryapi project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "countryapi.settings") + +application = get_asgi_application() diff --git a/api-integration-in-python/django-api/countryapi/settings.py b/api-integration-in-python/django-api/countryapi/settings.py new file mode 100644 index 0000000000..e1832f848b --- /dev/null +++ b/api-integration-in-python/django-api/countryapi/settings.py @@ -0,0 +1,131 @@ +""" +Django settings for countryapi project. + +Generated by 'django-admin startproject' using Django 6.1. + +For more information on this file, see +https://docs.djangoproject.com/en/6.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = ( + "django-insecure-cl)!rffwsl_fs)bjbug%l%91hwm&8l)^th2^stgpay*+j9#@f+" +) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "countries", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "countryapi.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "countryapi.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/6.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/6.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.1/howto/static-files/ + +STATIC_URL = "static/" + + +# Email +# https://docs.djangoproject.com/en/6.1/topics/email/#topic-email-configuration + +MAILERS = { + "default": { + "BACKEND": "django.core.mail.backends.console.EmailBackend", + }, +} diff --git a/api-integration-in-python/django-api/countryapi/urls.py b/api-integration-in-python/django-api/countryapi/urls.py new file mode 100644 index 0000000000..0d1011dac1 --- /dev/null +++ b/api-integration-in-python/django-api/countryapi/urls.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from django.urls import path, include + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("countries.urls")), +] diff --git a/api-integration-in-python/django-api/countryapi/wsgi.py b/api-integration-in-python/django-api/countryapi/wsgi.py new file mode 100644 index 0000000000..4fe52b05ee --- /dev/null +++ b/api-integration-in-python/django-api/countryapi/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for countryapi project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "countryapi.settings") + +application = get_wsgi_application() diff --git a/api-integration-in-python/django-api/manage.py b/api-integration-in-python/django-api/manage.py new file mode 100755 index 0000000000..72aca1e1e0 --- /dev/null +++ b/api-integration-in-python/django-api/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "countryapi.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/api-integration-in-python/fastapi-api/app.py b/api-integration-in-python/fastapi-api/app.py new file mode 100644 index 0000000000..e2886531a6 --- /dev/null +++ b/api-integration-in-python/fastapi-api/app.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from pydantic import BaseModel, Field + +app = FastAPI() + + +def _find_next_id(): + return max(country.country_id for country in countries) + 1 + + +class Country(BaseModel): + country_id: int = Field(default_factory=_find_next_id, alias="id") + name: str + capital: str + area: int + + +countries = [ + Country(id=1, name="Thailand", capital="Bangkok", area=513120), + Country(id=2, name="Australia", capital="Canberra", area=7617930), + Country(id=3, name="Egypt", capital="Cairo", area=1010408), +] + + +@app.get("/countries") +async def get_countries(): + return countries + + +@app.post("/countries", status_code=201) +async def add_country(country: Country): + countries.append(country) + return country diff --git a/api-integration-in-python/flask-api/app.py b/api-integration-in-python/flask-api/app.py new file mode 100644 index 0000000000..272fd13bdb --- /dev/null +++ b/api-integration-in-python/flask-api/app.py @@ -0,0 +1,28 @@ +from flask import Flask, request, jsonify + +app = Flask(__name__) + +countries = [ + {"id": 1, "name": "Thailand", "capital": "Bangkok", "area": 513120}, + {"id": 2, "name": "Australia", "capital": "Canberra", "area": 7617930}, + {"id": 3, "name": "Egypt", "capital": "Cairo", "area": 1010408}, +] + + +def _find_next_id(): + return max(country["id"] for country in countries) + 1 + + +@app.get("/countries") +def get_countries(): + return jsonify(countries) + + +@app.post("/countries") +def add_country(): + if request.is_json: + country = request.get_json() + country["id"] = _find_next_id() + countries.append(country) + return country, 201 + return {"error": "Request must be JSON"}, 415 diff --git a/api-integration-in-python/requirements.txt b/api-integration-in-python/requirements.txt new file mode 100644 index 0000000000..2de3669c63 --- /dev/null +++ b/api-integration-in-python/requirements.txt @@ -0,0 +1,6 @@ +Django==6.1 +Flask==3.1.3 +djangorestframework==3.18.0 +fastapi==0.141.1 +requests==2.34.2 +uvicorn==0.52.4