From e45bf760b688f4afc243b4d23b7344a1be81ab3f Mon Sep 17 00:00:00 2001 From: linuskmr Date: Tue, 21 Dec 2021 21:00:58 +0100 Subject: [PATCH] Add demo fastapi routes and models --- backend/README.md | 32 +++++++++++++ backend/app/main.py | 8 ---- backend/requirements.txt | 5 ++- backend/src/__init__.py | 0 backend/src/main.py | 82 ++++++++++++++++++++++++++++++++++ backend/src/models/__init__.py | 0 backend/src/models/project.py | 25 +++++++++++ backend/src/models/record.py | 27 +++++++++++ backend/src/models/tag.py | 16 +++++++ backend/src/routes/__init__.py | 0 backend/src/routes/projects.py | 66 +++++++++++++++++++++++++++ backend/src/routes/records.py | 67 +++++++++++++++++++++++++++ backend/src/routes/tags.py | 54 ++++++++++++++++++++++ 13 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 backend/README.md delete mode 100644 backend/app/main.py create mode 100644 backend/src/__init__.py create mode 100644 backend/src/main.py create mode 100644 backend/src/models/__init__.py create mode 100644 backend/src/models/project.py create mode 100644 backend/src/models/record.py create mode 100644 backend/src/models/tag.py create mode 100644 backend/src/routes/__init__.py create mode 100644 backend/src/routes/projects.py create mode 100644 backend/src/routes/records.py create mode 100644 backend/src/routes/tags.py diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..bafdeb9 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,32 @@ +# Juggl API + +## Installation + +Minimum Python version: 3.10 + +Create virtual environment: + +``` +$ python3.10 -m venv/ ./venv/ +``` + +Activate virtual environment (Linux): + +``` +$ source venv/bin/activate +``` + + +Activate virtual environment (Windows): + +``` +$ .venv\Scripts\activate +``` + +## Guidelines + +All routes and should be async for maximum performance + +Always specify the `response_model` in the route decorator and use the same type as type hint for the function return +type. FastAPI is unfortunately not yet smart enough to determine the `response_model` from the return type hint. +Maybe we should consider making a pull request... \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index d786432..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import FastAPI - -app = FastAPI() - - -@app.get("/") -async def root(): - return {"message": "Hello World"} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 97dc7cd..ff6f180 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,2 +1,3 @@ -fastapi -uvicorn +uvicorn~=0.15.0 +fastapi~=0.70.0 +pydantic~=1.8.2 \ No newline at end of file diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..e69872b --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,82 @@ +import argparse + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.routes.projects import router as project_router +from src.routes.tags import router as tag_router +from src.routes.records import router as record_router + + +description = """ +Welcome to the Juggl API. + +This API allows you to retrieve your timetracking data. +""" + +app = FastAPI( + title="Juggl API", + description=description, + contact={ # Contact information. See https://fastapi.tiangolo.com/tutorial/metadata/ + "name": "Juggl Management", + "url": "https://juggl.giller.dev/contact", + "email": "help@juggl.giller.dev", + }, + docs_url="/" # Documentation on start page +) +"""The main FastAPI instance""" + +# Enable CORS to allow requests from other domains/origins. +# See https://en.wikipedia.org/wiki/Cross-origin_resource_sharing +app.add_middleware( + CORSMiddleware, + allow_origins=[ + # Allow **all** foreign requests to access this API. + '*' + ], + allow_methods=["*"], + allow_headers=["*"], +) + +# Add subroutes +app.include_router(project_router) +app.include_router(tag_router) +app.include_router(record_router) + + +def main(): + args = parse_args() + if args.dev: + start_dev_server(args.port) + else: + start_production_server(args.port) + + +def parse_args() -> argparse.Namespace: + """Parses command line arguments on application startup.""" + + parser = argparse.ArgumentParser( + description="Juggl Backend v2", + formatter_class=argparse.ArgumentDefaultsHelpFormatter # show default values + ) + parser.add_argument("--dev", default=False, action=argparse.BooleanOptionalAction, + help="Start a dev server with auto-reloading") + parser.add_argument("--port", type=int, default=8192, help="Port on which the API should run") + return parser.parse_args() + + +def start_dev_server(port: int): + """Starts a development server with auto-reloading on port.""" + + uvicorn.run("main:app", reload=True, port=port) + + +def start_production_server(port: int): + """Starts a production, i.e. performance-optimized server on port.""" + + uvicorn.run("main:app", port=port) + + +if __name__ == "__main__": + main() diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/models/project.py b/backend/src/models/project.py new file mode 100644 index 0000000..4ad63fd --- /dev/null +++ b/backend/src/models/project.py @@ -0,0 +1,25 @@ +from datetime import datetime, timedelta +from pydantic import BaseModel, Field +from pydantic.json import timedelta_isoformat + + +class ProjectNew(BaseModel): + """Model used when a user creates a new project.""" + + name: str = Field(description="Name of the project", example="Learning") + + +class ProjectInfo(BaseModel): + """Model used when querying information about a module.""" + + name: str = Field(description="Name of the project", example="Learning") + start_date: datetime = Field(description="Project creation date", example=datetime.now()) + duration: timedelta = Field(description="Total tracked duration", example=timedelta(days=3, hours=5, minutes=47)) + records: int = Field(description="Total number of trackings/records", example=42) + + class Config: # TODO: Adding this config may be done with a decorator + """pydantic config""" + json_encoders = { + # Serialize timedeltas as ISO 8601 and not as float seconds, which is the default + timedelta: timedelta_isoformat, + } diff --git a/backend/src/models/record.py b/backend/src/models/record.py new file mode 100644 index 0000000..1d98c65 --- /dev/null +++ b/backend/src/models/record.py @@ -0,0 +1,27 @@ +from datetime import datetime, timedelta +from pydantic import BaseModel, Field +from pydantic.json import timedelta_isoformat + + +class RecordStart(BaseModel): + """Model used when a user starts a record.""" + + start: datetime = Field( + description="Start date of record", + example=datetime.now() - timedelta(hours=3) + ) + project: str = Field(description="Name of the project that should be tracked", example="Uni") + tags: list[str] = Field(description="List of tags by name", example=["Listening Lectures", "Not be concentrated"]) + + +class RecordInfo(BaseModel): + start: datetime = Field( + description="Start date of record", + example=datetime.now() - timedelta(hours=3) + ) + end: datetime = Field( + description="End date of record", + example=datetime.now() + ) + project: str = Field(description="Name of the project that should be tracked", example="Uni") + tags: list[str] = Field(description="List of tags by name", example=["Listening Lectures", "Not be concentrated"]) diff --git a/backend/src/models/tag.py b/backend/src/models/tag.py new file mode 100644 index 0000000..c217e64 --- /dev/null +++ b/backend/src/models/tag.py @@ -0,0 +1,16 @@ +from datetime import datetime, timedelta +from pydantic import BaseModel, Field +from pydantic.json import timedelta_isoformat + + +class TagNew(BaseModel): + """Model used when a user creates a new tag.""" + + name: str = Field(description="Name of the tag", example="Listening Lectures") + + +class TagInfo(BaseModel): + """Model used when querying information about a tag.""" + + name: str = Field(description="Name of the tag", example="Listening Lectures") + duration: timedelta = Field(description="Total tracked duration", example=timedelta(days=3, hours=5, minutes=47)) diff --git a/backend/src/routes/__init__.py b/backend/src/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/projects.py b/backend/src/routes/projects.py new file mode 100644 index 0000000..787498c --- /dev/null +++ b/backend/src/routes/projects.py @@ -0,0 +1,66 @@ +import logging +from datetime import datetime, timedelta + +from fastapi import APIRouter, status, Path +from src.models.project import ProjectNew, ProjectInfo + +router = APIRouter(prefix='/projects', tags=["Projects"]) + + +@router.get("/", response_model=list[ProjectInfo], summary="Get a list of all projects") +async def all_projects() -> list[ProjectInfo]: + """Returns a list of all projects""" + + return [ + ProjectInfo( + name="Learning", + start_date=datetime.now(), + duration=timedelta(days=3, hours=5, minutes=47), + records=42 + ), + ProjectInfo( + name="Sports", + start_date=datetime.now(), + duration=timedelta(hours=14, minutes=10), + records=10 + ), + ProjectInfo( + name="Gaming", + start_date=datetime.now(), + duration=timedelta(hours=3, minutes=2), + records=5 + ) + ] + + +@router.post("/", status_code=status.HTTP_201_CREATED, summary="Add a project") +async def add_project(project: ProjectNew): + """Add a project.""" + + print('adding project', project) + + +@router.get("/{name}", response_model=ProjectInfo, summary="Get a project by name") +async def get_project(name: str = Path(..., title="Name of the module")) -> ProjectInfo: + """Fetch a project by name.""" + + return ProjectInfo( + name=name, + start_date=datetime.now(), + duration=timedelta(hours=3, minutes=2), + records=5 + ) + + +@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a project by name") +async def delete_project(name: str = Path(..., title="Name of the module")): + """Delete a module specified by name.""" + + logging.debug(f"Deleting module {name}") + + +@router.patch("/{name}", summary="Apply partial updates to a project by name") +async def patch_project(project: ProjectNew, name: str = Path(..., title="Name of the module")): + """Apply partial updates to a project.""" + + logging.debug(f"Patching project {name} with {project}") diff --git a/backend/src/routes/records.py b/backend/src/routes/records.py new file mode 100644 index 0000000..3afa7dd --- /dev/null +++ b/backend/src/routes/records.py @@ -0,0 +1,67 @@ +import logging +from datetime import datetime, timedelta + +from fastapi import APIRouter, status, Path +from src.models.record import RecordStart, RecordInfo + +router = APIRouter(prefix="/records", tags=["Records"]) + + +@router.get("/", response_model=list[RecordInfo], summary="Get a list of all records") +async def all_tags() -> list[RecordInfo]: + """Returns a list of all records.""" + + return [ + RecordInfo( + start=datetime.now() - timedelta(hours=3), + end=datetime.now(), + project="Uni", + tags=["Listening Lectures", "Not be concentrated"] + ), + RecordInfo( + start=datetime.now() - timedelta(hours=7), + end=datetime.now() - timedelta(hours=4), + project="Uni", + tags=["Listening Lectures", "Not be concentrated"] + ) + ] + + +@router.post("/start", status_code=status.HTTP_201_CREATED, summary="Start a record") +async def start_record(record: RecordStart): + """Start a record.""" + + print('starting record', record) + + +@router.post("/end", status_code=status.HTTP_204_NO_CONTENT, summary="End a record") +async def end_record(record: RecordStart): + """End a record.""" + + print('end record', record) + + +@router.get("/{id}", response_model=RecordInfo, summary="Get a record by id") +async def get_record(id: str = Path(..., title="ID of the record")) -> RecordInfo: + """Fetch a record by id.""" + + return RecordInfo( + start=datetime.now() - timedelta(hours=3), + end=datetime.now(), + project="Uni", + tags=["Listening Lectures", "Not be concentrated"] + ) + + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a record by id") +async def delete_record(id: str = Path(..., title="ID of the record")): + """Delete a tag specified by name.""" + + logging.debug(f"Deleting record {id}") + + +@router.patch("/{id}", summary="Apply partial updates to a record by id") +async def patch_record(record: RecordInfo, id: str = Path(..., title="ID of the record")): + """Apply partial updates to a record.""" + + logging.debug(f"Patching record {id} with {record}") diff --git a/backend/src/routes/tags.py b/backend/src/routes/tags.py new file mode 100644 index 0000000..165d657 --- /dev/null +++ b/backend/src/routes/tags.py @@ -0,0 +1,54 @@ +import logging +from datetime import datetime, timedelta + +from fastapi import APIRouter, status, Path +from src.models.tag import TagNew, TagInfo + +router = APIRouter(prefix="/tags", tags=["Tags"]) + + +@router.get("/", response_model=list[TagInfo], summary="Get a list of all tags") +async def all_tags() -> list[TagInfo]: + """Returns a list of all tags.""" + + return [ + TagInfo( + name="Listening Lectures", + duration=timedelta(days=3, hours=5, minutes=47), + ), + TagInfo( + name="Doing homework", + duration=timedelta(hours=14, minutes=10), + ) + ] + + +@router.post("/", status_code=status.HTTP_201_CREATED, summary="Add a tag") +async def add_tag(tag: TagNew): + """Add a tag.""" + + print('adding tag', tag) + + +@router.get("/{name}", response_model=TagInfo, summary="Get a project by name") +async def get_tag(name: str = Path(..., title="Name of the tag")) -> TagInfo: + """Fetch a project by name.""" + + return TagInfo( + name=name, + duration=timedelta(hours=3, minutes=2), + ) + + +@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a tag by name") +async def delete_tag(name: str = Path(..., title="Name of the tag")): + """Delete a tag specified by name.""" + + logging.debug(f"Deleting tag {name}") + + +@router.patch("/{name}", summary="Apply partial updates to a tag by name") +async def patch_tag(tag: TagNew, name: str = Path(..., title="Name of the tag")): + """Apply partial updates to a tag.""" + + logging.debug(f"Patching tag {name} with {tag}")