├── .gitignore ├── .travis.yml ├── README.md ├── src ├── 01-environment-and-boilerplate │ ├── .gitignore │ ├── Dockerfile │ ├── api │ │ ├── app.py │ │ ├── project │ │ │ ├── __init__.py │ │ │ ├── routes.py │ │ │ └── views.py │ │ └── tests │ │ │ └── test_app.py │ ├── docker-compose.yml │ └── requirements.txt ├── 02-crud-api │ ├── .gitignore │ ├── Dockerfile │ ├── api │ │ ├── .cache │ │ │ └── v │ │ │ │ └── cache │ │ │ │ └── lastfailed │ │ ├── app.py │ │ ├── project │ │ │ ├── __init__.py │ │ │ ├── routes.py │ │ │ ├── schemas.py │ │ │ └── views.py │ │ └── tests │ │ │ ├── conftest.py │ │ │ ├── test_app.py │ │ │ └── test_task.py │ ├── docker-compose.yml │ └── requirements.txt └── 03-database-backend │ ├── Dockerfile │ ├── api │ ├── __pycache__ │ │ └── app.cpython-36.pyc │ ├── app.py │ ├── project │ │ ├── __init__.py │ │ ├── __pycache__ │ │ │ ├── __init__.cpython-36.pyc │ │ │ ├── routes.cpython-36.pyc │ │ │ └── views.cpython-36.pyc │ │ ├── models.py │ │ ├── routes.py │ │ ├── schemas.py │ │ ├── settings.py │ │ └── views.py │ └── tests │ │ ├── __pycache__ │ │ └── test_app.cpython-36.pyc │ │ ├── conftest.py │ │ ├── test_app.py │ │ └── test_task.py │ ├── docker-compose.yml │ ├── requirements-dev.txt │ └── requirements.txt └── tutorial ├── 01-environment-and-boilerplate.md ├── 02-crud-api.md └── 03-database-backend.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .cache/ 3 | __pycache__/ 4 | .pytest_cache/ 5 | 6 | *.db -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | cache: pip 4 | 5 | python: 6 | - "3.5" 7 | - "3.6" 8 | 9 | sudo: required 10 | services: 11 | - docker 12 | 13 | before_script: 14 | - cd ${TRAVIS_BUILD_DIR}/src/01-environment-and-boilerplate 15 | - docker build -t base:chapter1 . 16 | 17 | - cd ${TRAVIS_BUILD_DIR}/src/02-crud-api 18 | - docker build -t base:chapter2 . 19 | 20 | - cd ${TRAVIS_BUILD_DIR}/src/03-database-backend 21 | - docker build -t base:chapter3 . 22 | 23 | script: 24 | - docker run --rm -it base:chapter1 apistar test 25 | - docker run --rm -it base:chapter2 apistar test 26 | - docker run --rm -it base:chapter3 apistar test 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # API Star from scratch 2 | [![Build Status](https://travis-ci.org/servomac/apistar-from-scratch.svg?branch=master)](https://travis-ci.org/servomac/apistar-from-scratch) 3 | 4 | [API Star](https://github.com/tomchristie/apistar) is a web framework at early stages, centered in API construction. Its development is leaded by Tom Christie, the developer of [Django REST framework](http://www.django-rest-framework.org/). 5 | 6 | See [this slides](http://www.encode.io/talks/rethinking-the-web-api-framework/assets/player/KeynoteDHTMLPlayer.html#0) from his talk in the DjangoCon Europe. There is also a [community forum]() of the framework. 7 | 8 | This is a step by step tutorial to develop a simple API using this framework, heavily inspired by [JavaScript Stack from Scratch](https://github.com/verekia/js-stack-from-scratch). 9 | 10 | > This project is a work in process, that could be parallel to the development and new versions of API Star. Please, feel free to open an issue and/or fork this project to contribute. Hopefully this could be used as an open and live discussion about API development good practices. 11 | 12 | ## Table of Contents 13 | 14 | [01 - Set up the environment and create your first project](/tutorial/01-environment-and-boilerplate.md#readme) 15 | 16 | [02 - A basic CRUD API with TDD](/tutorial/02-crud-api.md#readme) *(WIP)* 17 | 18 | [03 - Database Backend](/tutorial/03-database-backend.md#readme) *(WIP)* 19 | 20 | [04 - Add User Authentication](/tutorial/04-add-user-authentication) *(Pending)* 21 | 22 | ## Related content 23 | 24 | [API Star's Poll Tutorial](https://github.com/agiliq/apistar-polls-tutorial) 25 | [APIStar project template](https://github.com/juancarlospaco/apistar-project-template) -------------------------------------------------------------------------------- /src/01-environment-and-boilerplate/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /src/01-environment-and-boilerplate/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | WORKDIR /app/ 4 | COPY requirements.txt /app/ 5 | 6 | RUN pip3 install -U pip \ 7 | && pip3 install -r requirements.txt 8 | 9 | ADD ./api/ /app/ 10 | -------------------------------------------------------------------------------- /src/01-environment-and-boilerplate/api/app.py: -------------------------------------------------------------------------------- 1 | from apistar import App 2 | from project.routes import routes 3 | 4 | app = App(routes=routes) 5 | -------------------------------------------------------------------------------- /src/01-environment-and-boilerplate/api/project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/servomac/apistar-from-scratch/c5dd3dc103c29072d49d77888e9d4df089a47ecb/src/01-environment-and-boilerplate/api/project/__init__.py -------------------------------------------------------------------------------- /src/01-environment-and-boilerplate/api/project/routes.py: -------------------------------------------------------------------------------- 1 | from apistar import Include, Route 2 | from apistar.docs import docs_routes 3 | from apistar.statics import static_routes 4 | from project.views import welcome 5 | 6 | routes = [ 7 | Route('/', 'GET', welcome), 8 | Include('/docs', docs_routes), 9 | Include('/static', static_routes) 10 | ] 11 | -------------------------------------------------------------------------------- /src/01-environment-and-boilerplate/api/project/views.py: -------------------------------------------------------------------------------- 1 | def welcome(name=None): 2 | if name is None: 3 | return {'message': 'Welcome to API Star!'} 4 | return {'message': 'Welcome to API Star, %s!' % name} 5 | -------------------------------------------------------------------------------- /src/01-environment-and-boilerplate/api/tests/test_app.py: -------------------------------------------------------------------------------- 1 | from apistar.test import TestClient 2 | from project.views import welcome 3 | 4 | 5 | def test_welcome(): 6 | """ 7 | Testing a view directly. 8 | """ 9 | data = welcome() 10 | assert data == {'message': 'Welcome to API Star!'} 11 | 12 | 13 | def test_http_request(): 14 | """ 15 | Testing a view, using the test client. 16 | """ 17 | client = TestClient() 18 | response = client.get('http://localhost/') 19 | assert response.status_code == 200 20 | assert response.json() == {'message': 'Welcome to API Star!'} 21 | -------------------------------------------------------------------------------- /src/01-environment-and-boilerplate/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | pythonbase: 5 | build: . 6 | 7 | api: 8 | extends: pythonbase 9 | entrypoint: apistar 10 | command: ["run", "--host", "0.0.0.0", "--port", "80"] 11 | ports: 12 | - "8080:80" 13 | working_dir: /app 14 | volumes: 15 | - "./api:/app" 16 | -------------------------------------------------------------------------------- /src/01-environment-and-boilerplate/requirements.txt: -------------------------------------------------------------------------------- 1 | apistar==0.1.17 2 | -------------------------------------------------------------------------------- /src/02-crud-api/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /src/02-crud-api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | WORKDIR /app/ 4 | COPY requirements.txt /app/ 5 | 6 | RUN pip3 install -U pip \ 7 | && pip3 install -r requirements.txt 8 | 9 | ADD ./api/ /app/ 10 | -------------------------------------------------------------------------------- /src/02-crud-api/api/.cache/v/cache/lastfailed: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /src/02-crud-api/api/app.py: -------------------------------------------------------------------------------- 1 | from apistar import App 2 | from project.routes import routes 3 | 4 | app = App(routes=routes) 5 | -------------------------------------------------------------------------------- /src/02-crud-api/api/project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/servomac/apistar-from-scratch/c5dd3dc103c29072d49d77888e9d4df089a47ecb/src/02-crud-api/api/project/__init__.py -------------------------------------------------------------------------------- /src/02-crud-api/api/project/routes.py: -------------------------------------------------------------------------------- 1 | from apistar import Include, Route 2 | from apistar.docs import docs_routes 3 | from apistar.statics import static_routes 4 | from project.views import welcome 5 | from project.views import list_tasks, add_task, delete_task, patch_task 6 | 7 | task_routes = [ 8 | Route('/', 'GET', list_tasks), 9 | Route('/', 'POST', add_task), 10 | Route('/{task_id}/', 'DELETE', delete_task), 11 | Route('/{task_id}/', 'PATCH', patch_task), 12 | ] 13 | 14 | routes = [ 15 | Route('/', 'GET', welcome), 16 | Include('/task', task_routes), 17 | Include('/docs', docs_routes), 18 | Include('/static', static_routes) 19 | ] 20 | -------------------------------------------------------------------------------- /src/02-crud-api/api/project/schemas.py: -------------------------------------------------------------------------------- 1 | from apistar import schema 2 | 3 | 4 | class TaskDefinition(schema.String): 5 | max_length = 128 6 | default = None 7 | 8 | class TaskId(schema.Integer): 9 | pass 10 | 11 | class Task(schema.Object): 12 | properties = { 13 | 'id': schema.Integer(default=None), 14 | 'definition': TaskDefinition, 15 | 'completed': schema.Boolean, 16 | } 17 | -------------------------------------------------------------------------------- /src/02-crud-api/api/project/views.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from typing import List 3 | 4 | from apistar.http import Response 5 | from apistar.schema import Boolean 6 | 7 | from project.schemas import Task, TaskDefinition 8 | 9 | # global variable representing the 10 | # list of tasks, indexed by id 11 | tasks = {} 12 | 13 | # https://stackoverflow.com/questions/9604516/simple-number-generator 14 | counter = itertools.count(1).__next__ 15 | 16 | def welcome(name=None): 17 | if name is None: 18 | return {'message': 'Welcome to API Star!'} 19 | return {'message': 'Welcome to API Star, %s!' % name} 20 | 21 | def list_tasks() -> List[Task]: 22 | return [Task(tasks[id]) for id in tasks] 23 | 24 | def add_task(definition: TaskDefinition) -> Response: 25 | """ 26 | Add a new task. It receives its definition as an argument 27 | and sets an autoincremental id in the Task constructor. 28 | 29 | Returns the created serialized object and 201 status code on success. 30 | 31 | TODO: 32 | - maybe this counter could be implemented as an injectable component? 33 | """ 34 | if not definition: 35 | Response(422, {'error': 'You should provide a definition of the task.'}) 36 | 37 | id = counter() 38 | tasks[id] = { 39 | 'id': id, 40 | 'definition': definition, 41 | 'completed': False, 42 | } 43 | return Response(Task(tasks[id]), status=201) 44 | 45 | def delete_task(task_id: int) -> Response: 46 | if task_id not in tasks: 47 | return Response({}, status=404) 48 | 49 | del tasks[task_id] 50 | return Response({}, status=204) 51 | 52 | def patch_task(task_id: int, completed: Boolean) -> Response: 53 | """ 54 | Mark an specific task referenced by id as completed/incompleted. 55 | 56 | Returns the entire updated serialized object and 200 status code on success. 57 | """ 58 | if task_id not in tasks: 59 | return Response({}, status=404) 60 | 61 | tasks[task_id]['completed'] = completed 62 | return Response(Task(tasks[task_id]), status=200) 63 | -------------------------------------------------------------------------------- /src/02-crud-api/api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from apistar.test import TestClient 4 | 5 | 6 | @pytest.fixture(scope="session") 7 | def client(): 8 | return TestClient() 9 | -------------------------------------------------------------------------------- /src/02-crud-api/api/tests/test_app.py: -------------------------------------------------------------------------------- 1 | from apistar.test import TestClient 2 | from project.views import welcome 3 | 4 | def test_welcome(): 5 | """ 6 | Testing a view directly. 7 | """ 8 | data = welcome() 9 | assert data == {'message': 'Welcome to API Star!'} 10 | 11 | def test_http_request(client): 12 | """ 13 | Testing a view, using the test client. 14 | """ 15 | response = client.get('http://localhost/') 16 | assert response.status_code == 200 17 | assert response.json() == {'message': 'Welcome to API Star!'} 18 | 19 | -------------------------------------------------------------------------------- /src/02-crud-api/api/tests/test_task.py: -------------------------------------------------------------------------------- 1 | from apistar.test import TestClient 2 | 3 | """ 4 | TODO: 5 | - FIX the tests are interdependent 6 | - test the schema validation, it's not currently working 7 | - put: assert that have becomed permanently marked as True, 8 | and is not only the response 9 | """ 10 | 11 | task_endpoint = '/task/' 12 | 13 | new_task = {'definition': 'test task'} 14 | added_task = {'definition': 'test task', 'completed': False, 'id': 1} 15 | 16 | def test_list_tasks(client): 17 | response = client.get(task_endpoint) 18 | assert response.status_code == 200 19 | assert response.json() == [] 20 | 21 | def test_add_task(client): 22 | response = client.post(task_endpoint, new_task) 23 | assert response.status_code == 201 24 | 25 | assert response.json() == added_task 26 | 27 | # TODO pending https://github.com/tomchristie/apistar/issues/6 28 | #def test_add_task_without_data(): 29 | # response = client.post(task_endpoint) 30 | # assert response.status_code == 422 31 | # assert response.json() == {'error': 'You should provide a definition of the task.'} 32 | 33 | def test_list_an_added_task(client): 34 | response = client.get(task_endpoint) 35 | assert response.status_code == 200 36 | assert response.json() == [added_task] 37 | 38 | # add another task 39 | response = client.post(task_endpoint, new_task) 40 | 41 | # the same fields, but an autoincremented id 42 | # (maybe collection's ChainMap is more expressive?) 43 | second_added_task = dict(added_task, **{'id': 2}) 44 | 45 | response = client.get(task_endpoint) 46 | assert response.status_code == 200 47 | assert response.json() == [added_task, second_added_task] 48 | 49 | 50 | def _task_id_endpoint(task_id): 51 | return f'{task_endpoint}{task_id}/' 52 | 53 | def test_delete_task(client): 54 | response = client.delete(_task_id_endpoint(-1)) 55 | assert response.status_code == 404 56 | 57 | existing_id = added_task.get('id') 58 | response = client.delete(_task_id_endpoint(existing_id)) 59 | assert response.status_code == 204 60 | 61 | response = client.delete(_task_id_endpoint(existing_id)) 62 | assert response.status_code == 404 63 | 64 | def test_patch_task(client): 65 | response = client.patch(_task_id_endpoint(-1), {'completed': True}) 66 | assert response.status_code == 404 67 | 68 | # new task, initially created as incomplete 69 | response = client.post(task_endpoint, new_task) 70 | assert response.status_code == 201 71 | new_task_serialized = response.json() 72 | assert new_task_serialized.get('completed') == False 73 | 74 | existing_id = new_task_serialized.get('id') 75 | response = client.patch(_task_id_endpoint(existing_id), {'completed': True}) 76 | assert response.status_code == 200 77 | assert response.json() == dict(new_task_serialized, **{'completed': True}) 78 | -------------------------------------------------------------------------------- /src/02-crud-api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | base: 5 | build: . 6 | entrypoint: apistar 7 | working_dir: /app 8 | volumes: 9 | - "./api:/app" 10 | 11 | api: 12 | extends: base 13 | command: ["run", "--host", "0.0.0.0", "--port", "80"] 14 | ports: 15 | - "8080:80" 16 | 17 | test: 18 | extends: base 19 | command: ["test"] -------------------------------------------------------------------------------- /src/02-crud-api/requirements.txt: -------------------------------------------------------------------------------- 1 | apistar==0.1.17 2 | -------------------------------------------------------------------------------- /src/03-database-backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | WORKDIR /app/ 4 | COPY requirements.txt /app/ 5 | COPY requirements-dev.txt /app/ 6 | 7 | RUN pip3 install -U pip \ 8 | && pip3 install -r requirements.txt \ 9 | && pip3 install -r requirements-dev.txt 10 | 11 | ADD ./api/ /app/ 12 | -------------------------------------------------------------------------------- /src/03-database-backend/api/__pycache__/app.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/servomac/apistar-from-scratch/c5dd3dc103c29072d49d77888e9d4df089a47ecb/src/03-database-backend/api/__pycache__/app.cpython-36.pyc -------------------------------------------------------------------------------- /src/03-database-backend/api/app.py: -------------------------------------------------------------------------------- 1 | from apistar import App 2 | from apistar.commands import create_tables 3 | 4 | from project.routes import routes 5 | from project.settings import settings 6 | 7 | app = App(routes=routes, settings=settings, commands=[create_tables]) 8 | -------------------------------------------------------------------------------- /src/03-database-backend/api/project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/servomac/apistar-from-scratch/c5dd3dc103c29072d49d77888e9d4df089a47ecb/src/03-database-backend/api/project/__init__.py -------------------------------------------------------------------------------- /src/03-database-backend/api/project/__pycache__/__init__.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/servomac/apistar-from-scratch/c5dd3dc103c29072d49d77888e9d4df089a47ecb/src/03-database-backend/api/project/__pycache__/__init__.cpython-36.pyc -------------------------------------------------------------------------------- /src/03-database-backend/api/project/__pycache__/routes.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/servomac/apistar-from-scratch/c5dd3dc103c29072d49d77888e9d4df089a47ecb/src/03-database-backend/api/project/__pycache__/routes.cpython-36.pyc -------------------------------------------------------------------------------- /src/03-database-backend/api/project/__pycache__/views.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/servomac/apistar-from-scratch/c5dd3dc103c29072d49d77888e9d4df089a47ecb/src/03-database-backend/api/project/__pycache__/views.cpython-36.pyc -------------------------------------------------------------------------------- /src/03-database-backend/api/project/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base 2 | from sqlalchemy import Column, Integer, String, Boolean 3 | 4 | Base = declarative_base() 5 | 6 | class Task(Base): 7 | __tablename__ = 'task' 8 | id = Column(Integer, primary_key=True) 9 | definition = Column(String(128)) 10 | completed = Column(Boolean, default=False) 11 | 12 | def __repr__(self): 13 | return ''.format(self.id) 14 | 15 | def serialize(self): 16 | return { 17 | 'id': self.id, 18 | 'definition': self.definition, 19 | 'completed': self.completed, 20 | } 21 | -------------------------------------------------------------------------------- /src/03-database-backend/api/project/routes.py: -------------------------------------------------------------------------------- 1 | from apistar import Include, Route 2 | from apistar.docs import docs_routes 3 | from apistar.statics import static_routes 4 | from project.views import welcome 5 | from project.views import list_tasks, add_task, delete_task, patch_task 6 | 7 | task_routes = [ 8 | Route('/', 'GET', list_tasks), 9 | Route('/', 'POST', add_task), 10 | Route('/{task_id}/', 'DELETE', delete_task), 11 | Route('/{task_id}/', 'PATCH', patch_task), 12 | ] 13 | 14 | routes = [ 15 | Route('/', 'GET', welcome), 16 | Include('/task', task_routes), 17 | Include('/docs', docs_routes), 18 | Include('/static', static_routes) 19 | ] 20 | -------------------------------------------------------------------------------- /src/03-database-backend/api/project/schemas.py: -------------------------------------------------------------------------------- 1 | from apistar import schema 2 | 3 | 4 | class TaskDefinition(schema.String): 5 | max_length = 128 6 | default = None 7 | 8 | class TaskId(schema.Integer): 9 | minimum = 1 10 | 11 | class Task(schema.Object): 12 | properties = { 13 | 'id': schema.Integer(default=None), 14 | 'definition': TaskDefinition, 15 | 'completed': schema.Boolean(default=False), 16 | } 17 | -------------------------------------------------------------------------------- /src/03-database-backend/api/project/settings.py: -------------------------------------------------------------------------------- 1 | from apistar import environment, schema 2 | from project.models import Base 3 | 4 | class Env(environment.Environment): 5 | properties = { 6 | 'DEBUG': schema.Boolean(default=False), 7 | 'DATABASE_URL': schema.String(default='sqlite:///test.db'), 8 | } 9 | 10 | env = Env() 11 | 12 | settings = { 13 | 'DEBUG': env['DEBUG'], 14 | 'DATABASE': { 15 | 'URL': env['DATABASE_URL'], 16 | 'METADATA': Base.metadata, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/03-database-backend/api/project/views.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from typing import List 3 | 4 | from apistar.backends import SQLAlchemy 5 | from apistar.http import Response 6 | from apistar.schema import Boolean 7 | 8 | from project.schemas import Task, TaskDefinition 9 | from project.models import Task as TaskModel 10 | 11 | # global variable representing the 12 | # list of tasks, indexed by id 13 | tasks = {} 14 | 15 | # https://stackoverflow.com/questions/9604516/simple-number-generator 16 | counter = itertools.count(1).__next__ 17 | 18 | def welcome(name=None): 19 | if name is None: 20 | return {'message': 'Welcome to API Star!'} 21 | return {'message': 'Welcome to API Star, %s!' % name} 22 | 23 | def list_tasks(db: SQLAlchemy) -> List[Task]: 24 | session = db.session_class() 25 | tasks = session.query(TaskModel).all() 26 | return [Task(t) for t in tasks] 27 | 28 | def add_task(db: SQLAlchemy, definition: TaskDefinition) -> Response: 29 | """ 30 | Add a new task. It receives its definition as an argument 31 | and sets an autoincremental id in the Task constructor. 32 | 33 | Returns the created serialized object and 201 status code on success. 34 | """ 35 | if not definition: 36 | Response(422, {'error': 'You should provide a definition of the task.'}) 37 | 38 | task = TaskModel(definition=definition) 39 | session = db.session_class() 40 | session.add(task) 41 | session.commit() 42 | 43 | return Response(task.serialize(), status=201) 44 | 45 | def delete_task(db: SQLAlchemy, task_id: int) -> Response: 46 | if task_id not in tasks: 47 | return Response({}, status=404) 48 | 49 | del tasks[task_id] 50 | return Response({}, status=204) 51 | 52 | def patch_task(db: SQLAlchemy, task_id: int, completed: Boolean) -> Response: 53 | """ 54 | Mark an specific task referenced by id as completed/incompleted. 55 | 56 | Returns the entire updated serialized object and 200 status code on success. 57 | """ 58 | if task_id not in tasks: 59 | return Response({}, status=404) 60 | 61 | tasks[task_id]['completed'] = completed 62 | return Response(Task(tasks[task_id]), status=200) 63 | -------------------------------------------------------------------------------- /src/03-database-backend/api/tests/__pycache__/test_app.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/servomac/apistar-from-scratch/c5dd3dc103c29072d49d77888e9d4df089a47ecb/src/03-database-backend/api/tests/__pycache__/test_app.cpython-36.pyc -------------------------------------------------------------------------------- /src/03-database-backend/api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from apistar.test import TestClient 4 | 5 | 6 | @pytest.fixture(scope="session") 7 | def client(): 8 | return TestClient() 9 | -------------------------------------------------------------------------------- /src/03-database-backend/api/tests/test_app.py: -------------------------------------------------------------------------------- 1 | from apistar.test import TestClient 2 | from project.views import welcome 3 | 4 | def test_welcome(): 5 | """ 6 | Testing a view directly. 7 | """ 8 | data = welcome() 9 | assert data == {'message': 'Welcome to API Star!'} 10 | 11 | def test_http_request(client): 12 | """ 13 | Testing a view, using the test client. 14 | """ 15 | response = client.get('http://localhost/') 16 | assert response.status_code == 200 17 | assert response.json() == {'message': 'Welcome to API Star!'} 18 | 19 | -------------------------------------------------------------------------------- /src/03-database-backend/api/tests/test_task.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import apistar 4 | from apistar.backends import SQLAlchemy 5 | from apistar.test import TestClient 6 | from apistar.test import CommandLineRunner 7 | 8 | from app import app 9 | 10 | 11 | runner = CommandLineRunner(app) 12 | 13 | 14 | @pytest.fixture 15 | def clear_db(scope="function"): 16 | yield 17 | db_backend = SQLAlchemy.build(app.settings) 18 | db_backend.drop_tables() 19 | 20 | 21 | def test_list_tasks(monkeypatch, clear_db): 22 | client = TestClient(app) 23 | 24 | def mock_get_current_app(): 25 | return app 26 | 27 | monkeypatch.setattr(apistar.main, 'get_current_app', mock_get_current_app) 28 | 29 | result = runner.invoke(['create_tables']) 30 | assert 'Tables created' in result.output 31 | 32 | response = client.get('/task/') 33 | assert response.status_code == 200 34 | assert response.json() == [] 35 | 36 | 37 | def test_add_task(monkeypatch, clear_db): 38 | client = TestClient(app) 39 | 40 | def mock_get_current_app(): 41 | return app 42 | 43 | monkeypatch.setattr(apistar.main, 'get_current_app', mock_get_current_app) 44 | 45 | runner.invoke(['create_tables']) 46 | response = client.post('/task/', {'definition': 'test task'}) 47 | assert response.status_code == 201 48 | 49 | assert response.json() == { 50 | 'definition': 'test task', 51 | 'completed': False, 52 | 'id': 1, 53 | } 54 | 55 | # TODO pending https://github.com/tomchristie/apistar/issues/6 56 | #def test_add_task_without_data(): 57 | # response = client.post(task_endpoint) 58 | # assert response.status_code == 422 59 | # assert response.json() == {'error': 'You should provide a definition of the task.'} 60 | 61 | 62 | # def test_list_an_added_task(client): 63 | # response = client.get(task_endpoint) 64 | # assert response.status_code == 200 65 | # assert response.json() == [added_task] 66 | 67 | # # add another task 68 | # response = client.post(task_endpoint, new_task) 69 | 70 | # # the same fields, but an autoincremented id 71 | # # (maybe collection's ChainMap is more expressive?) 72 | # second_added_task = dict(added_task, **{'id': 2}) 73 | 74 | # response = client.get(task_endpoint) 75 | # assert response.status_code == 200 76 | # assert response.json() == [added_task, second_added_task] 77 | 78 | 79 | # def _task_id_endpoint(task_id): 80 | # return f'{task_endpoint}{task_id}/' 81 | 82 | # def test_delete_task(client): 83 | # response = client.delete(_task_id_endpoint(-1)) 84 | # assert response.status_code == 404 85 | 86 | # existing_id = added_task.get('id') 87 | # response = client.delete(_task_id_endpoint(existing_id)) 88 | # assert response.status_code == 204 89 | 90 | # response = client.delete(_task_id_endpoint(existing_id)) 91 | # assert response.status_code == 404 92 | 93 | # def test_patch_task(client): 94 | # response = client.patch(_task_id_endpoint(-1), {'completed': True}) 95 | # assert response.status_code == 404 96 | 97 | # # new task, initially created as incomplete 98 | # response = client.post(task_endpoint, new_task) 99 | # assert response.status_code == 201 100 | # new_task_serialized = response.json() 101 | # assert new_task_serialized.get('completed') == False 102 | 103 | # existing_id = new_task_serialized.get('id') 104 | # response = client.patch(_task_id_endpoint(existing_id), {'completed': True}) 105 | # assert response.status_code == 200 106 | # assert response.json() == dict(new_task_serialized, **{'completed': True}) 107 | -------------------------------------------------------------------------------- /src/03-database-backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | base: 5 | build: . 6 | entrypoint: apistar 7 | working_dir: /app 8 | volumes: 9 | - "./api:/app" 10 | 11 | api: 12 | extends: base 13 | command: ["run", "--host", "0.0.0.0", "--port", "80"] 14 | ports: 15 | - "8080:80" 16 | depends_on: 17 | - db 18 | environment: 19 | - DATABASE_URL=postgresql+psycopg2://user:pass@db/dbname 20 | - DEBUG=True 21 | 22 | db: 23 | image: postgres:9.6-alpine 24 | environment: 25 | POSTGRES_DB: "dbname" 26 | POSTGRES_USER: "user" 27 | POSTGRES_PASSWORD: "pass" 28 | 29 | test: 30 | extends: base 31 | command: ["test"] 32 | -------------------------------------------------------------------------------- /src/03-database-backend/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | ipdb==0.10.3 2 | -------------------------------------------------------------------------------- /src/03-database-backend/requirements.txt: -------------------------------------------------------------------------------- 1 | apistar==0.1.17 2 | psycopg2==2.7.1 3 | SQLAlchemy==1.1.10 4 | -------------------------------------------------------------------------------- /tutorial/01-environment-and-boilerplate.md: -------------------------------------------------------------------------------- 1 | # 01 - Set up the environment and create your first project 2 | 3 | Code for this chapter available [here](/src/01-environment-and-boilerplate/). 4 | 5 | In this section we will create a basic environment to develop our API using Docker, initialize a `requirements.txt` and generate some basic layout using `apistar` commands. 6 | 7 | > Prequisites: You need a working Python3 installation for this introduction; but if you don't have one, don't worry! We will use Docker to containeraize our environment. You can also use a docker python image to use pip. 8 | 9 | ## Install apistar and create a layout project 10 | 11 | Pip is a tool to install Python packages. Using it, install the [API Star framework](https://github.com/tomchristie/apistar). 12 | 13 | ```sh 14 | $ pip3 install apistar 15 | ``` 16 | 17 | The package also provides a command line tool that can create a basic layout for a new API Star project. It also generates an example welcome view and routing for the documentation and static files. 18 | 19 | ```sh 20 | $ mkdir api/ 21 | $ apistar new api --layout minimal 22 | ``` 23 | 24 | You can also run the app via the apistar shell tool, and view the API interactive documentation in `localhost:8000/docs/`. 25 | 26 | ```sh 27 | $ cd api/ 28 | $ apistar run 29 | ``` 30 | 31 | Create a `requirements.txt` file to define the project dependencies. We will use pip freeze to pin the specific version of the packages. 32 | 33 | ```sh 34 | $ pip3 freeze | grep apistar > requirements.txt 35 | $ cat requirements.txt 36 | apistar==0.1.17 37 | ``` 38 | 39 | ## Set up an environment using Docker 40 | 41 | Docker is a really good way to pack and ship your applications; offering a good tooling ecosystem for a devops environment, from the first commit in your dev environment to the wild. 42 | 43 | TODO Install docker and docker-compose using the official documentation, and then come back. 44 | 45 | We will define a simple docker image to add the `requirements.txt` and install the dependencies via the following `Dockerfile`. 46 | 47 | ```Docker 48 | FROM python:3.6 49 | 50 | WORKDIR /app/ 51 | COPY requirements.txt /app/ 52 | 53 | RUN pip3 install -U pip \ 54 | && pip3 install -r requirements.txt 55 | 56 | ADD ./api/ /app/ 57 | ``` 58 | 59 | Build the image. The parent image `python:3.6` default command is `python3`, so running it without additional parameters will provide an interactive python shell (with the dependencies installed; try `import apistar`): 60 | 61 | ``` 62 | $ docker build -t basepython . 63 | $ docker run --rm -it basepython 64 | Python 3.6.1 (default, Jun 8 2017, 21:43:55) 65 | [GCC 4.9.2] on linux 66 | Type "help", "copyright", "credits" or "license" for more information. 67 | >>> 68 | ``` 69 | 70 | > Just a reminder about the docker run parameters. Here we will execute an interactive shell. Given that we are running an ephimeral container, we pass `rm` parameter to remove it after you exit the shell and the container stops. `it` (interactive, tty) are used here to interact via stdin with the process in a terminal; as the `man docker run` specifies *-t [...] can be used, for example, to run a throwaway interactive shell*. 71 | 72 | Finally we can execute apistar in our python docker image! Let's see its usage: 73 | 74 | ``` 75 | $ docker run --rm basepython apistar 76 | Usage: apistar [OPTIONS] COMMAND [ARGS]... 77 | 78 | API Star 79 | 80 | Options: 81 | --version Display the `apistar` version number. 82 | --help Show this message and exit. 83 | 84 | Commands: 85 | new Create a new project in TARGET_DIR. 86 | run Run the current app. 87 | schema Output an API Schema. 88 | test Run the test suite. 89 | ``` 90 | 91 | `new` generates a new project in the directory passed as parameter, creating an example welcome endpoint and basic project structure. 92 | 93 | ``` 94 | $ mkdir api 95 | $ docker run --rm -v `pwd`/api:/app basepython \ 96 | apistar new . --layout standard 97 | 98 | ./app.py 99 | ./tests/test_app.py 100 | ./tests/__pycache__/test_app.cpython-36.pyc 101 | ./__pycache__/app.cpython-36.pyc 102 | ./project/routes.py 103 | ./project/__init__.py 104 | ./project/views.py 105 | ./project/__pycache__/routes.cpython-36.pyc 106 | ./project/__pycache__/views.cpython-36.pyc 107 | ./project/__pycache__/__init__.cpython-36.pyc 108 | ``` 109 | 110 | > `-v` is mounts the host `api` directory recently created to the container `/app` container, the working directory defined in the `Dockerfile`, as a volume. 111 | 112 | Explore the sample code, run the project and enjoy the autogenerated documentation. 113 | 114 | ``` 115 | $ docker run \ 116 | --rm -v `pwd`/api:/app \ 117 | -p 8080:8080 \ 118 | basepython \ 119 | apistar run --host 0.0.0.0 120 | 121 | Starting up... 122 | * Running on http://0.0.0.0:8080/ (Press CTRL+C to quit) 123 | * Restarting with stat 124 | * Debugger is active! 125 | * Debugger PIN: 957-476-005 126 | ``` 127 | 128 | ``` 129 | $ http localhost:8080 130 | 131 | HTTP/1.0 200 OK 132 | Content-Length: 35 133 | Content-Type: application/json 134 | Date: Fri, 16 Jun 2017 23:10:14 GMT 135 | Server: Werkzeug/0.12.2 Python/3.6.1 136 | 137 | { 138 | "message": "Welcome to API Star!" 139 | } 140 | ``` 141 | 142 | > I will be using [httpie](https://httpie.org/) client to interact with the API. Don't forget to get a glance of the autogenerated API doc and try the interactive client at [http://localhost:8080/docs/](http://localhost:8080/docs/). 143 | 144 | Congrats, your first API Star project is up and running ;-) 145 | 146 | ### Appendix: A docker-compose.yml project definition 147 | 148 | We will use a docker compose yaml file to specify our base service and to avoid passing at every run the same parameters. It allows to define the diferent services of our environment (i.e. a database) and to orchestate them. 149 | 150 | ``` 151 | version: '2' 152 | 153 | services: 154 | pythonbase: 155 | build: . 156 | 157 | api: 158 | extends: pythonbase 159 | entrypoint: apistar 160 | command: ["run", "--host", "0.0.0.0", "--port", "80"] 161 | working_dir: /app 162 | volumes: 163 | - "./api:/app" 164 | ``` 165 | 166 | Now you can run the project using `docker-compose` instead of a docker command with multiple parameters. This will become handy when we add more complexity to the stack: 167 | 168 | ```sh 169 | $ docker-compose run -d api 170 | ``` 171 | 172 | Next section: [02 - A basic CRUD API with TDD](02-crud-api.md#readme) 173 | 174 | Back to the [table of contents](https://github.com/servomac/apistar-from-scratch#table-of-contents). -------------------------------------------------------------------------------- /tutorial/02-crud-api.md: -------------------------------------------------------------------------------- 1 | # 02 - A basic CRUD API with TDD 2 | 3 | Code for this chapter available [here](/src/02-crud-api/). 4 | 5 | With our fresh environment we will start to get hands on with API Star. In this section we will construct a simple CRUD (*Create, Read, Update and Delete*) api **to manage a TODO list**. 6 | 7 | We will put special enfasis on testing, using `py.test` testing framework, which is included in API Star. We will follow some principles of TDD (*Test Driven Development*). 8 | 9 | ## Just another TODO list API 10 | 11 | The TODO List is the 'hello world' of the XXI century. In this basic example, a user must be able to create or delete a **Task**, as well as mark it as completed. It should also list every **Task**, and maybe allow to filter by a completed query string param. 12 | 13 | The endpoints of our API that will be developed in this chapter are: 14 | 15 | * `GET /task/`: Retrieve a list of tasks. 16 | * `POST /task/`: Create a new task. 17 | * `PATCH /task/{id}/`: Update some field of an specific task by id (i.e. mark as completed) 18 | * `DELETE /task/{id}/`: Delete a task by id. 19 | 20 | ## Testing a dumb view 21 | 22 | We will start using the code structure generated in Chapter 1, adding the `GET` and `POST` verbs over the /task/ endpoint to allow to create new tasks and list them. To temporarily persist our list of tasks, we will use an in memory global variable. In next chapters we will substitute this using SQLAlchemy as database backend. 23 | 24 | Furthermore we will create a Task schema. API Star provides a typing system to define your API interface. `Schemas` are defined as specification of input/output data contracts for your views, validating your input and serializing your output (as libraries such as [marshmallow](https://marshmallow.readthedocs.io/en/latest/) do). 25 | 26 | Let's start adding some dumb views in our `project/views.py` file: 27 | 28 | ```python 29 | def list_tasks(): 30 | return {} 31 | 32 | def create_task(): 33 | return {} 34 | ``` 35 | 36 | And add their routes in `routes.py` taking advantatge of `Include` to isolate the task related routes: 37 | 38 | ```python 39 | [...] 40 | from project.views import list_tasks, create_task 41 | 42 | task_routes = [ 43 | Route('/', 'GET', list_tasks), 44 | Route('/', 'POST', create_task), 45 | ] 46 | 47 | routes = [ 48 | Route('/', 'GET', welcome), 49 | Include('/task', task_routes), 50 | Include('/docs', docs_routes), 51 | Include('/static', static_routes) 52 | ] 53 | ``` 54 | 55 | Now we have just created our first dumb connected views. But wait, were is the [Red-Green-Refactor](http://blog.cleancoder.com/uncle-bob/2014/12/17/TheCyclesOfTDD.html)? 56 | 57 | Some tests are generated by apistar in the example of chapter 1, so we can run them. Add this service to the `docker-compose.yml`, and we can move some of the parameters from the api service to the base: 58 | 59 | ``` 60 | version: '2' 61 | 62 | services: 63 | base: 64 | build: . 65 | entrypoint: apistar 66 | working_dir: /app 67 | volumes: 68 | - "./api:/app" 69 | 70 | api: 71 | extends: base 72 | command: ["run", "--host", "0.0.0.0", "--port", "80"] 73 | ports: 74 | - "8080:80" 75 | 76 | test: 77 | extends: base 78 | command: ["test"] 79 | ``` 80 | 81 | And simply run the test suite with: 82 | 83 | ```sh 84 | $ docker-compose run test 85 | ========================================================================= test session starts ========================================================================= 86 | platform linux -- Python 3.6.1, pytest-3.1.2, py-1.4.34, pluggy-0.4.0 87 | rootdir: /app, inifile: 88 | collected 2 items 89 | 90 | tests/test_app.py .. 91 | 92 | ====================================================================== 2 passed in 0.02 seconds ======================================================================= 93 | ``` 94 | 95 | ### py.test 96 | 97 | Add two simple tests at `tests/test_app.py` that call the views and assert that they return an empty dictionary. 98 | 99 | ```python 100 | from project.views import list_tasks, add_task 101 | 102 | def test_add_task(): 103 | assert add_task() == {} 104 | 105 | def test_list_tasks(): 106 | assert list_tasks() == {} 107 | ``` 108 | 109 | Run again the tests and.. nice! We are green! Let's start implementing the views. List tasks initially should return an empty list. 110 | 111 | ```python 112 | # test_app.py 113 | def test_list_tasks(): 114 | assert list_tasks() == [] 115 | 116 | # views.py 117 | tasks = [] 118 | 119 | def list_tasks(): 120 | """ Return a list of tasks """ 121 | return tasks 122 | ``` 123 | > *I will present both the test and the modified view. The idea behind is to drive our development thinking about the expected behaviour, by writting the test before the code.* 124 | 125 | #### TestClient 126 | 127 | API Star also provides a test client wrapping `requests` to test your app. We have tested the views directly, but we could also add a few http tests using this client. 128 | 129 | ```python 130 | from apistar.test import TestClient 131 | 132 | client = TestClient() 133 | 134 | def test_http_list_tasks(): 135 | response = client.get('/tasks/') 136 | assert response.status_code == 200 137 | assert response.json() == [] 138 | 139 | def test_http_add_task(): 140 | response = client.post('/tasks/') 141 | assert response.status_code == 200 142 | assert response.json() == {} 143 | ``` 144 | 145 | In our case, we will define a [pytest fixture at directory level](https://docs.pytest.org/en/latest/example/simple.html#package-directory-level-fixtures-setups) to provide our client as parameters of the tests (see [/tests/conftest.py](/src/02-crud-api/tests/conftest.py)). 146 | 147 | ## Schemas 148 | 149 | To define the interface of the views, create an schema of our **Task** object and a simple **TaskDefinition**, constraining the max length of the string on input. 150 | 151 | ```python 152 | # schemas.py 153 | from apistar import schema 154 | 155 | class TaskDefinition(schema.String): 156 | max_length = 128 157 | 158 | class Task(schema.Object): 159 | properties = { 160 | 'definition': TaskDefinition, 161 | 'completed': schema.Boolean(default=False), 162 | } 163 | ``` 164 | 165 | This schemas allow us to validate the input of the create task, and serialize the output of the both views. API Star allows us to annotate the route handlers with the expected input and output, it's like magic :-) 166 | 167 | ```python 168 | # views.py 169 | from typing import List 170 | 171 | from project.schemas import Task, TaskDefinition 172 | 173 | def list_task() -> List[Task]: 174 | return [Task(t) for t in tasks] 175 | 176 | def add_task(definition: TaskDefinition) -> Task: 177 | new_task = Task({'definition': definition}) 178 | task.append(new_task) 179 | return new_task 180 | 181 | # test_task.py 182 | from apistar.test import TestClient 183 | 184 | task_endpoint = '/task/' 185 | client = TestClient() 186 | 187 | new_task = {'definition': 'test task'} 188 | added_task = {'definition': 'test task', 'completed': False} 189 | 190 | def test_list_tasks(): 191 | response = client.get(task_endpoint) 192 | assert response.status_code == 200 193 | assert response.json() == [] 194 | 195 | def test_add_task(): 196 | response = client.post(task_endpoint, new_task) 197 | assert response.status_code == 200 198 | 199 | assert response.json() == added_task 200 | 201 | def test_list_an_added_task(): 202 | response = client.get(task_endpoint) 203 | assert response.status_code == 200 204 | assert response.json() == [added_task] 205 | 206 | test_add_task() 207 | response = client.get(task_endpoint) 208 | assert response.status_code == 200 209 | assert response.json() == [added_task, added_task] 210 | ``` 211 | > Note: We have extracted the tests related with the `/task/` API endpoint in `tests/test_task.py`. 212 | 213 | Take a look to the views annotated with the Schemas previously defined. `list_task` returns a list of serialized task items, and `add_task` gets input via type annotation, with a validated definition. 214 | 215 | Until now we have just tested the happy path, and that's just naive! But we will let that as an exercise to the reader. Fork the project and add some tests. 216 | 217 | ### Delete a task or mark it as completed 218 | 219 | Let's add the pending functionality: mark a todo task as completed and delete a task. 220 | 221 | Until now our schema does not include an unique `id` value for tasks, so we cannot identify a concrete task (because different tasks could have the same definition and completion state). Also the data structure that we have used to persist the tasks in memory, a list, it's not specially suited for indexing by key, deleting tasks, etc. 222 | 223 | The first thing that we will do is write some tests for the new autoincremental id of the tasks, then change the schema (adding an `'id': schema.Integer(default=None)` in the properties) and use a generator to implement an autoincremental counter for our tasks ids, before starting to write the new views. 224 | 225 | ```python 226 | # views.py 227 | import itertools 228 | counter = itertools.count(1).__next__ 229 | 230 | def add_task(definition: TaskDefinition) -> Task: 231 | """ 232 | Add a new task. It receives its definition as an argument 233 | and sets an autoincremental id in the Task constructor. 234 | """ 235 | new_task = Task({'id': counter(), 'definition': definition}) 236 | tasks.append(new_task) 237 | return new_task 238 | ``` 239 | > A lot of people argue that APIs should not expose internal indexes to the public, and some propose to use uids or other mechanisms to expose ours objects to the world. They are right. But this is a tutorial from scratch, let's go step by step! 240 | 241 | And voilà, now the tasks have an id and are uniquely identifiable. See the [tests](/src/02-crud-api/tests/test_task.py) for the simple case of adding two times the same task. 242 | 243 | Another refactor that will become extremly handy in the implementation of our views is replacing our list of tasks by a dictionary of tasks, with their brand new ids as key. Obviously updating and deleting operations would benefit from the direct access of a dictionary, both in code simplicity and computational complexity. 244 | 245 | ```python 246 | # views.py 247 | tasks = {} 248 | 249 | def list_tasks() -> List[Task]: 250 | """ Return a list of tasks """ 251 | return [Task(tasks[id]) for id in tasks] 252 | 253 | def add_task(definition: TaskDefinition) -> Task: 254 | """ 255 | Add a new task. It receives its definition as an argument 256 | and sets an autoincremental id in the Task constructor. 257 | """ 258 | id = counter() 259 | tasks[id] = Task({ 260 | 'id': id, 261 | 'definition': definition, 262 | 'completed' = False, 263 | }) 264 | return tasks[id] 265 | ``` 266 | 267 | The tests should still be green. But now, with this small changes, we should be capable of easily implement the delete and patch views. 268 | 269 | In the routes file we will be using the *curly braces syntax* for the url id parameter, to pass it to the `delete_task` method. API Start by default returns plain text and a 200 status code. But when a delete action is successfull a 204 should be returned, and a 404 if the specified task does not exist. `Response`s allows us to customize our responses. 270 | 271 | ```python 272 | # routes.py 273 | from project.views import list_tasks, add_task, delete_task 274 | 275 | task_routes = [ 276 | Route('/', 'GET', list_tasks), 277 | Route('/', 'POST', add_task), 278 | Route('/{task_id}/', 'DELETE', delete_task), 279 | ] 280 | 281 | # views.py 282 | from apistar.http import Response 283 | 284 | def delete_task(task_id: int) -> Response: 285 | if task_id not in tasks: 286 | return Response({}, status=404) 287 | 288 | del tasks[task_id] 289 | return Response({}, status=204) 290 | ``` 291 | 292 | > Note: I have also rewritten the view [add_task](/src/02-crud-api/project/views.py#L24) with this recently introduced Response. A POST to an API endpoint that is successfull should return a 201 status code and the created object. 293 | 294 | And now as an exercise, why you don't write your own method and routing to mark a task as completed? You can see our simple proposal in the final [source code of this chapter](/src/02-crud-api/project/views.py), method `patch_task`. 295 | 296 | Note that in this implementation, we are using a PATCH method with a *task_id* parameter from the URL and a *completed* boolean from the body of the request. As the [current README](https://github.com/tomchristie/apistar#url-routing) of apistar reads: 297 | 298 | > Parameters which do not correspond to a URL path parameter will be treated as query parameters for GET and DELETE requests, or part of the request body for POST, PUT, and PATCH requests. 299 | 300 | - *TODO appendix for the filtering of completed/uncompleted tasks via query string* 301 | 302 | Next section: [03 - Database backend](03-database-backend.md#readme) 303 | 304 | Back to the [table of contents](https://github.com/servomac/apistar-from-scratch#table-of-contents). -------------------------------------------------------------------------------- /tutorial/03-database-backend.md: -------------------------------------------------------------------------------- 1 | # 03 - Database backend 2 | 3 | *WORK IN PROGRESS* 4 | 5 | Code for this chapter available [here](/src/03-database-backend). 6 | 7 | In previous chapters we have presented apistar and constructed a rudimentary API to handle our own TODO list. But it lacks any kind of persistence! 8 | 9 | In this section we will introduce the database backends of API Star and add a database to our project. We will be using [SQLAlchemy](https://www.sqlalchemy.org/), but the framework also offers suport for [Django ORM](https://github.com/tomchristie/apistar#django-orm). 10 | 11 | ## PostgreSQL 12 | 13 | PostgreSQL will be used as our relational database. We need to install the postgres python connector, psycopg2, and SQLAlchemy to interact with the db, so update your `requirements.txt` and build again the docker-compose services. 14 | 15 | ```sh 16 | psycopg2==2.7.1 17 | SQLAlchemy==1.1.10 18 | ``` 19 | 20 | We will extend our `docker-compose.yml` with the definition of the new member of the stack, the db, that will be a dependency for the api container: 21 | 22 | ``` 23 | api: 24 | [..] 25 | depends_on: 26 | - db 27 | 28 | db: 29 | image: postgres:9.6-alpine 30 | environment: 31 | POSTGRES_DB: "dbname" 32 | POSTGRES_USER: "user" 33 | POSTGRES_PASSWORD: "pass" 34 | ``` 35 | 36 | ## Your first model 37 | 38 | We will use [declarative base](http://docs.sqlalchemy.org/en/latest/orm/extensions/declarative/api.html) from SQLAlchemy to create our *Task* model. Create a new file `models.py`: 39 | 40 | ```python 41 | from sqlalchemy.ext.declarative import declarative_base 42 | from sqlalchemy import Column, Integer, String, Boolean 43 | 44 | Base = declarative_base() 45 | 46 | class Task(Base): 47 | __tablename__ = 'task' 48 | id = Column(Integer, primary_key=True) 49 | definition = Column(String(128)) 50 | completed = Column(Boolean, default=False) 51 | 52 | def __repr__(self): 53 | return ''.format(self.id) 54 | 55 | ``` 56 | 57 | ## Settings and db integration 58 | 59 | First of all, we need to say to our app where to find the db and the [metadata](http://docs.sqlalchemy.org/en/latest/orm/extensions/declarative/basic_use.html#accessing-the-metadata) from the SQLAlchemy declarative base. 60 | 61 | Let's define the settings from environment variables. API Star has a class Environment that allows to easily read them. Create a new file `settings.py` in the project path: 62 | 63 | ```python 64 | from apistar import environment, schema 65 | from project.models import Base 66 | 67 | class Env(environment.Environment): 68 | properties = { 69 | 'DEBUG': schema.Boolean(default=False), 70 | 'DATABASE_URL': schema.String(default='sqlite://'), 71 | } 72 | 73 | env = Env() 74 | 75 | settings = { 76 | 'DEBUG': env['DEBUG'], 77 | 'DATABASE': { 78 | 'URL': env['DATABASE_URL'], 79 | 'METADATA': Base.metadata, 80 | } 81 | } 82 | ``` 83 | > Note that the settings `DATABASE` parameter is specific of SQLAlchemy. If you want to use Django ORM, take a look to the [APIStar readme](https://github.com/tomchristie/apistar#django-orm). 84 | 85 | And pass those settings as an argument to your app constructor: 86 | 87 | ```python 88 | # app.yml 89 | [...] 90 | from project.settings import settings 91 | 92 | app = App(routes=routes, settings=settings) 93 | ``` 94 | 95 | Define the environment vars needed in the docker compose api service. Set debug as `True` and the database url to connect to the postgres db. 96 | 97 | ``` 98 | # docker-compose.yml 99 | api: 100 | [...] 101 | environment: 102 | - DATABASE_URL=postgresql+psycopg2://user:pass@db/dbname 103 | - DEBUG=True 104 | ``` 105 | > Note: Thanks to the internal docker dns I can directly write the name of the service on the database connection url! Take a look of the [SQLAlchemy](http://docs.sqlalchemy.org/en/latest/dialects/postgresql.html) PostgreSQL documentation to construct the URI. 106 | 107 | ### Commands: create_tables 108 | 109 | Use `create_tables` command to create your tables. Import it from apistar and pass it to the App constructor. 110 | 111 | ```python 112 | # app.py 113 | from apistar.commands import create_tables 114 | [...] 115 | 116 | app = App(routes=routes, settings=settings, commands=[create_tables]) 117 | ``` 118 | 119 | And invoke the command: 120 | 121 | ``` 122 | $ apistar create_tables 123 | Tables created 124 | ``` 125 | 126 | > Note: There is some work in progress for custom commands in the framework. See the issue [#65](https://github.com/tomchristie/apistar/issues/65) and pull request [#62](https://github.com/tomchristie/apistar/pull/62). 127 | 128 | ### Access the database from views 129 | 130 | You can inject the [SQLAlchemy component](https://github.com/tomchristie/apistar/blob/38a5d7a307f268ca3e0e03f6a8779a643c545798/apistar/backends/sqlalchemy_backend.py) into the views. It has `engine`, `session_class` and `metadata` as attributes. 131 | 132 | ``` 133 | # views.py 134 | from apistar.backends import SQLAlchemy 135 | from project.models import Task as TaskModel 136 | 137 | def add_task(db: SQLAlchemy, definition: TaskDefinition) -> Response: 138 | task = TaskModel(definition=definition) 139 | session = db.session_class() 140 | session.add(task) 141 | session.commit() 142 | return Response(Task(task), status=201) 143 | ``` 144 | 145 | ## Testing 146 | 147 | --------------------------------------------------------------------------------