diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1b0dfd5..b5f653c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -83,7 +83,7 @@ jobs: -p 5003:8765 \ ${{ env.IMAGE }}-final:latest - name: Install requirements - run: docker exec fastapi-tdd pip install black==23.1.0 flake8==6.0.0 isort==5.12.0 pytest==7.2.2 + run: docker exec fastapi-tdd pip install black==23.12.1 flake8==7.0.0 isort==5.13.2 pytest==7.4.4 - name: Pytest run: docker exec fastapi-tdd python -m pytest . - name: Flake8 @@ -98,7 +98,7 @@ jobs: runs-on: ubuntu-latest needs: [build, test] env: - HEROKU_APP_NAME: radiant-everglades-49858 + HEROKU_APP_NAME: quiet-citadel-80656 HEROKU_REGISTRY_IMAGE: registry.heroku.com/${HEROKU_APP_NAME}/summarizer steps: - name: Checkout diff --git a/project/Dockerfile b/project/Dockerfile index 79f4fd9..cb8c488 100644 --- a/project/Dockerfile +++ b/project/Dockerfile @@ -1,5 +1,5 @@ # pull official base image -FROM python:3.11.2-slim-buster +FROM python:3.12.1-slim-bookworm # set working directory WORKDIR /usr/src/app @@ -10,7 +10,7 @@ ENV PYTHONUNBUFFERED 1 # install system dependencies RUN apt-get update \ - && apt-get -y install netcat gcc postgresql \ + && apt-get -y install netcat-traditional gcc postgresql \ && apt-get clean # install python dependencies diff --git a/project/Dockerfile.prod b/project/Dockerfile.prod index 27dff14..3832eff 100644 --- a/project/Dockerfile.prod +++ b/project/Dockerfile.prod @@ -3,11 +3,11 @@ ########### # pull official base image -FROM python:3.11.2-slim-buster as builder +FROM python:3.12.1-slim-bookworm as builder # install system dependencies RUN apt-get update \ - && apt-get -y install gcc postgresql \ + && apt-get -y install netcat-traditional gcc postgresql \ && apt-get clean # set work directory @@ -24,10 +24,10 @@ RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requir # lint COPY . /usr/src/app/ -RUN pip install black==23.1.0 flake8==6.0.0 isort==5.12.0 +RUN pip install black==23.12.1 flake8==7.0.0 isort==5.13.2 RUN flake8 . -RUN black --exclude=migrations . -RUN isort . +RUN black --exclude=migrations . --check +RUN isort . --check-only ######### @@ -35,7 +35,7 @@ RUN isort . ######### # pull official base image -FROM python:3.11.2-slim-buster +FROM python:3.12.1-slim-bookworm # create directory for the app user RUN mkdir -p /home/app @@ -57,7 +57,7 @@ ENV TESTING 0 # install system dependencies RUN apt-get update \ - && apt-get -y install netcat gcc postgresql \ + && apt-get -y install netcat-traditional gcc postgresql \ && apt-get clean # install python dependencies @@ -65,7 +65,7 @@ COPY --from=builder /usr/src/app/wheels /wheels COPY --from=builder /usr/src/app/requirements.txt . RUN pip install --upgrade pip RUN pip install --no-cache /wheels/* -RUN pip install "uvicorn[standard]==0.21.1" +RUN pip install "uvicorn[standard]==0.26.0" # add app COPY . . diff --git a/project/app/api/crud.py b/project/app/api/crud.py index 2f3e314..9b58efb 100644 --- a/project/app/api/crud.py +++ b/project/app/api/crud.py @@ -4,11 +4,6 @@ from app.models.tortoise import TextSummary -async def get_all() -> List: - summaries = await TextSummary.all().values() - return summaries - - async def get(id: int) -> Union[dict, None]: summary = await TextSummary.filter(id=id).first().values() if summary: @@ -16,6 +11,11 @@ async def get(id: int) -> Union[dict, None]: return None +async def get_all() -> List: + summaries = await TextSummary.all().values() + return summaries + + async def post(payload: SummaryPayloadSchema) -> int: summary = TextSummary(url=payload.url, summary="") await summary.save() diff --git a/project/app/api/summaries.py b/project/app/api/summaries.py index 787fe83..10ce945 100644 --- a/project/app/api/summaries.py +++ b/project/app/api/summaries.py @@ -15,11 +15,6 @@ router = APIRouter() -@router.get("/", response_model=List[SummarySchema]) -async def read_all_summaries() -> List[SummarySchema]: - return await crud.get_all() - - @router.get("/{id}/", response_model=SummarySchema) async def read_summary(id: int = Path(..., gt=0)) -> SummarySchema: summary = await crud.get(id) @@ -29,13 +24,18 @@ async def read_summary(id: int = Path(..., gt=0)) -> SummarySchema: return summary +@router.get("/", response_model=List[SummarySchema]) +async def read_all_summaries() -> List[SummarySchema]: + return await crud.get_all() + + @router.post("/", response_model=SummaryResponseSchema, status_code=201) async def create_summary( payload: SummaryPayloadSchema, background_tasks: BackgroundTasks ) -> SummaryResponseSchema: summary_id = await crud.post(payload) - background_tasks.add_task(generate_summary, summary_id, payload.url) + background_tasks.add_task(generate_summary, summary_id, str(payload.url)) response_object = {"id": summary_id, "url": payload.url} return response_object diff --git a/project/app/config.py b/project/app/config.py index 05895b0..cc80aa4 100644 --- a/project/app/config.py +++ b/project/app/config.py @@ -1,7 +1,8 @@ import logging from functools import lru_cache -from pydantic import AnyUrl, BaseSettings +from pydantic import AnyUrl +from pydantic_settings import BaseSettings log = logging.getLogger("uvicorn") diff --git a/project/db/Dockerfile b/project/db/Dockerfile index 954ad8f..69c75af 100644 --- a/project/db/Dockerfile +++ b/project/db/Dockerfile @@ -1,5 +1,5 @@ # pull official base image -FROM postgres:15 +FROM postgres:16 # run create.sql on init ADD create.sql /docker-entrypoint-initdb.d diff --git a/project/migrations/models/0_20230318165417_init.py b/project/migrations/models/0_20240122171233_init.py similarity index 100% rename from project/migrations/models/0_20230318165417_init.py rename to project/migrations/models/0_20240122171233_init.py diff --git a/project/requirements-dev.txt b/project/requirements-dev.txt index c86a47f..475b437 100644 --- a/project/requirements-dev.txt +++ b/project/requirements-dev.txt @@ -1,8 +1,8 @@ -black==23.1.0 -flake8==6.0.0 -isort==5.12.0 -pytest==7.2.2 -pytest-cov==4.0.0 -pytest-xdist==3.2.1 +black==23.12.1 +flake8==7.0.0 +isort==5.13.2 +pytest==7.4.4 +pytest-cov==4.1.0 +pytest-xdist==3.5.0 -r requirements.txt diff --git a/project/requirements.txt b/project/requirements.txt index 743f2ca..9176370 100644 --- a/project/requirements.txt +++ b/project/requirements.txt @@ -1,8 +1,9 @@ -aerich==0.7.1 -asyncpg==0.27.0 -fastapi==0.94.1 -gunicorn==20.1.0 -httpx==0.23.3 +aerich==0.7.2 +asyncpg==0.29.0 +fastapi==0.109.0 +gunicorn==21.0.1 +httpx==0.26.0 newspaper3k==0.2.8 -tortoise-orm==0.19.3 -uvicorn==0.21.1 +pydantic-settings==2.1.0 +tortoise-orm==0.20.0 +uvicorn==0.26.0 diff --git a/project/tests/test_summaries.py b/project/tests/test_summaries.py index 266f334..16284f3 100644 --- a/project/tests/test_summaries.py +++ b/project/tests/test_summaries.py @@ -16,7 +16,7 @@ def mock_generate_summary(summary_id, url): ) assert response.status_code == 201 - assert response.json()["url"] == "https://foo.bar" + assert response.json()["url"] == "https://foo.bar/" def test_create_summaries_invalid_json(test_app): @@ -25,16 +25,20 @@ def test_create_summaries_invalid_json(test_app): assert response.json() == { "detail": [ { + "input": {}, "loc": ["body", "url"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "type": "missing", + "url": "https://errors.pydantic.dev/2.5/v/missing", } ] } response = test_app.post("/summaries/", data=json.dumps({"url": "invalid://url"})) assert response.status_code == 422 - assert response.json()["detail"][0]["msg"] == "URL scheme not permitted" + assert ( + response.json()["detail"][0]["msg"] == "URL scheme should be 'http' or 'https'" + ) def test_read_summary(test_app_with_db, monkeypatch): @@ -53,7 +57,7 @@ def mock_generate_summary(summary_id, url): response_dict = response.json() assert response_dict["id"] == summary_id - assert response_dict["url"] == "https://foo.bar" + assert response_dict["url"] == "https://foo.bar/" assert response_dict["created_at"] @@ -67,10 +71,12 @@ def test_read_summary_incorrect_id(test_app_with_db): assert response.json() == { "detail": [ { + "ctx": {"gt": 0}, + "input": "0", "loc": ["path", "id"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - "ctx": {"limit_value": 0}, + "msg": "Input should be greater than 0", + "type": "greater_than", + "url": "https://errors.pydantic.dev/2.5/v/greater_than", } ] } @@ -107,7 +113,7 @@ def mock_generate_summary(summary_id, url): response = test_app_with_db.delete(f"/summaries/{summary_id}/") assert response.status_code == 200 - assert response.json() == {"id": summary_id, "url": "https://foo.bar"} + assert response.json() == {"id": summary_id, "url": "https://foo.bar/"} def test_remove_summary_incorrect_id(test_app_with_db): @@ -120,10 +126,12 @@ def test_remove_summary_incorrect_id(test_app_with_db): assert response.json() == { "detail": [ { + "ctx": {"gt": 0}, + "input": "0", "loc": ["path", "id"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - "ctx": {"limit_value": 0}, + "msg": "Input should be greater than 0", + "type": "greater_than", + "url": "https://errors.pydantic.dev/2.5/v/greater_than", } ] } @@ -148,7 +156,7 @@ def mock_generate_summary(summary_id, url): response_dict = response.json() assert response_dict["id"] == summary_id - assert response_dict["url"] == "https://foo.bar" + assert response_dict["url"] == "https://foo.bar/" assert response_dict["summary"] == "updated!" assert response_dict["created_at"] @@ -168,10 +176,12 @@ def mock_generate_summary(summary_id, url): 422, [ { + "type": "greater_than", "loc": ["path", "id"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - "ctx": {"limit_value": 0}, + "msg": "Input should be greater than 0", + "input": "0", + "ctx": {"gt": 0}, + "url": "https://errors.pydantic.dev/2.5/v/greater_than", } ], ], @@ -181,14 +191,18 @@ def mock_generate_summary(summary_id, url): 422, [ { + "type": "missing", "loc": ["body", "url"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "input": {}, + "url": "https://errors.pydantic.dev/2.5/v/missing", }, { + "type": "missing", "loc": ["body", "summary"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "input": {}, + "url": "https://errors.pydantic.dev/2.5/v/missing", }, ], ], @@ -198,9 +212,11 @@ def mock_generate_summary(summary_id, url): 422, [ { + "type": "missing", "loc": ["body", "summary"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "input": {"url": "https://foo.bar"}, + "url": "https://errors.pydantic.dev/2.5/v/missing", } ], ], @@ -213,6 +229,7 @@ def test_update_summary_invalid( f"/summaries/{summary_id}/", data=json.dumps(payload) ) assert response.status_code == status_code + print(response.json()["detail"]) assert response.json()["detail"] == detail @@ -222,4 +239,6 @@ def test_update_summary_invalid_url(test_app): data=json.dumps({"url": "invalid://url", "summary": "updated!"}), ) assert response.status_code == 422 - assert response.json()["detail"][0]["msg"] == "URL scheme not permitted" + assert ( + response.json()["detail"][0]["msg"] == "URL scheme should be 'http' or 'https'" + ) diff --git a/project/tests/test_summaries_unit.py b/project/tests/test_summaries_unit.py index bc810ba..9216b0c 100644 --- a/project/tests/test_summaries_unit.py +++ b/project/tests/test_summaries_unit.py @@ -13,7 +13,7 @@ def mock_generate_summary(summary_id, url): monkeypatch.setattr(summaries, "generate_summary", mock_generate_summary) test_request_payload = {"url": "https://foo.bar"} - test_response_payload = {"id": 1, "url": "https://foo.bar"} + test_response_payload = {"id": 1, "url": "https://foo.bar/"} async def mock_post(payload): return 1 @@ -35,16 +35,20 @@ def test_create_summaries_invalid_json(test_app): assert response.json() == { "detail": [ { + "type": "missing", "loc": ["body", "url"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "input": {}, + "url": "https://errors.pydantic.dev/2.5/v/missing", } ] } response = test_app.post("/summaries/", data=json.dumps({"url": "invalid://url"})) assert response.status_code == 422 - assert response.json()["detail"][0]["msg"] == "URL scheme not permitted" + assert ( + response.json()["detail"][0]["msg"] == "URL scheme should be 'http' or 'https'" + ) def test_read_summary(test_app, monkeypatch): @@ -120,7 +124,7 @@ async def mock_delete(id): response = test_app.delete("/summaries/1/") assert response.status_code == 200 - assert response.json() == {"id": 1, "url": "https://foo.bar"} + assert response.json() == {"id": 1, "url": "https://foo.bar/"} def test_remove_summary_incorrect_id(test_app, monkeypatch): @@ -171,10 +175,12 @@ async def mock_put(id, payload): 422, [ { + "type": "greater_than", "loc": ["path", "id"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - "ctx": {"limit_value": 0}, + "msg": "Input should be greater than 0", + "input": "0", + "ctx": {"gt": 0}, + "url": "https://errors.pydantic.dev/2.5/v/greater_than", } ], ], @@ -184,14 +190,18 @@ async def mock_put(id, payload): 422, [ { + "type": "missing", "loc": ["body", "url"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "input": {}, + "url": "https://errors.pydantic.dev/2.5/v/missing", }, { + "type": "missing", "loc": ["body", "summary"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "input": {}, + "url": "https://errors.pydantic.dev/2.5/v/missing", }, ], ], @@ -201,9 +211,11 @@ async def mock_put(id, payload): 422, [ { + "type": "missing", "loc": ["body", "summary"], - "msg": "field required", - "type": "value_error.missing", + "msg": "Field required", + "input": {"url": "https://foo.bar"}, + "url": "https://errors.pydantic.dev/2.5/v/missing", } ], ], @@ -220,3 +232,14 @@ async def mock_put(id, payload): response = test_app.put(f"/summaries/{summary_id}/", data=json.dumps(payload)) assert response.status_code == status_code assert response.json()["detail"] == detail + + +def test_update_summary_invalid_url(test_app): + response = test_app.put( + "/summaries/1/", + data=json.dumps({"url": "invalid://url", "summary": "updated!"}), + ) + assert response.status_code == 422 + assert ( + response.json()["detail"][0]["msg"] == "URL scheme should be 'http' or 'https'" + )