From d48266fe7cd23da40601bcf52467dd0d0e28918c Mon Sep 17 00:00:00 2001 From: Kyle O'Brien <65071578+TawneeOwl@users.noreply.github.com> Date: Tue, 24 Dec 2024 07:05:59 +0000 Subject: [PATCH 1/3] Add in fast api versionizer --- app/main.py | 8 +++++++ app/routers/case_information.py | 1 - app/routers/security.py | 1 - .../generated/requirements-development.txt | 2 ++ .../generated/requirements-production.txt | 2 ++ .../generated/requirements-testing.txt | 2 ++ requirements/source/requirements-base.in | 1 + tests/auth/test_auth.py | 24 +++++++++++-------- .../case_adaptations/test_case_adaptations.py | 8 +++---- tests/cases/test_case_request.py | 12 +++++----- tests/conftest.py | 4 ++-- 11 files changed, 41 insertions(+), 24 deletions(-) diff --git a/app/main.py b/app/main.py index 5e2930a0..4688983f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from .routers import case_information, security from .config.docs import config as docs_config +from fastapi_versionizer.versionizer import Versionizer def create_app() -> FastAPI: @@ -8,4 +9,11 @@ def create_app() -> FastAPI: app.include_router(case_information.router) app.include_router(security.router) + Versionizer( + app=app, + prefix_format="/v{major}", + semantic_version_format="{major}", + latest_prefix="/latest", + sort_routes=True, + ).versionize() return app diff --git a/app/routers/case_information.py b/app/routers/case_information.py index b1786a16..9a31909e 100644 --- a/app/routers/case_information.py +++ b/app/routers/case_information.py @@ -13,7 +13,6 @@ from app.auth.security import get_current_active_user from app.models.users import UserScopes - logger = structlog.getLogger(__name__) diff --git a/app/routers/security.py b/app/routers/security.py index a099906f..4d7b4452 100644 --- a/app/routers/security.py +++ b/app/routers/security.py @@ -13,7 +13,6 @@ from app.db import get_session - router = APIRouter( responses={404: {"description": "Not found"}}, ) diff --git a/requirements/generated/requirements-development.txt b/requirements/generated/requirements-development.txt index 01ee593f..ff9c094d 100644 --- a/requirements/generated/requirements-development.txt +++ b/requirements/generated/requirements-development.txt @@ -20,6 +20,7 @@ dnspython==2.7.0 email-validator==2.2.0 fastapi[standard]==0.115.2 fastapi-cli[standard]==0.0.5 +fastapi-versionizer==4.0.1 filelock==3.15.4 freezegun==1.5.1 gitdb==4.0.11 @@ -37,6 +38,7 @@ mako==1.3.5 markdown-it-py==3.0.0 markupsafe==2.1.5 mdurl==0.1.2 +natsort==8.4.0 nodeenv==1.9.1 packaging==24.1 passlib==1.7.4 diff --git a/requirements/generated/requirements-production.txt b/requirements/generated/requirements-production.txt index a32b79c3..bbbd93a5 100644 --- a/requirements/generated/requirements-production.txt +++ b/requirements/generated/requirements-production.txt @@ -17,6 +17,7 @@ dnspython==2.7.0 email-validator==2.2.0 fastapi[standard]==0.115.2 fastapi-cli[standard]==0.0.5 +fastapi-versionizer==4.0.1 greenlet==3.0.3 h11==0.14.0 httpcore==1.0.6 @@ -28,6 +29,7 @@ mako==1.3.5 markdown-it-py==3.0.0 markupsafe==2.1.5 mdurl==0.1.2 +natsort==8.4.0 passlib==1.7.4 psycopg2-binary==2.9.9 pycparser==2.22 diff --git a/requirements/generated/requirements-testing.txt b/requirements/generated/requirements-testing.txt index 59893092..ffb9d5be 100644 --- a/requirements/generated/requirements-testing.txt +++ b/requirements/generated/requirements-testing.txt @@ -17,6 +17,7 @@ dnspython==2.7.0 email-validator==2.2.0 fastapi[standard]==0.115.2 fastapi-cli[standard]==0.0.5 +fastapi-versionizer==4.0.1 freezegun==1.5.1 greenlet==3.0.3 h11==0.14.0 @@ -30,6 +31,7 @@ mako==1.3.5 markdown-it-py==3.0.0 markupsafe==2.1.5 mdurl==0.1.2 +natsort==8.4.0 packaging==24.1 passlib==1.7.4 pluggy==1.5.0 diff --git a/requirements/source/requirements-base.in b/requirements/source/requirements-base.in index 24ce6835..b7bc1264 100644 --- a/requirements/source/requirements-base.in +++ b/requirements/source/requirements-base.in @@ -15,3 +15,4 @@ argon2_cffi structlog typer tabulate==0.9.0 +fastapi-versionizer==4.0.1 \ No newline at end of file diff --git a/tests/auth/test_auth.py b/tests/auth/test_auth.py index 2f7ca988..0ac5af73 100644 --- a/tests/auth/test_auth.py +++ b/tests/auth/test_auth.py @@ -18,7 +18,9 @@ def test_auth_fail_case(client: TestClient): - response = client.post("/cases/", json={"category": "Housing", "name": "John Doe"}) + response = client.post( + "latest/cases/", json={"category": "Housing", "name": "John Doe"} + ) json = response.json() assert json["detail"] == "Not authenticated" assert response.status_code == 401 @@ -26,7 +28,7 @@ def test_auth_fail_case(client: TestClient): def test_create_case_disabled_user(client: TestClient, auth_token_disabled_user): response = client.get( - "/cases/", headers={"Authorization": f"Bearer {auth_token_disabled_user}"} + "latest/cases/", headers={"Authorization": f"Bearer {auth_token_disabled_user}"} ) json = response.json() assert json["detail"] == "User Disabled" @@ -35,7 +37,7 @@ def test_create_case_disabled_user(client: TestClient, auth_token_disabled_user) def test_username_token_fail(client: TestClient): response = client.post( - "/token", + "latest/token", data={"username": "fake_user", "password": "incorrect"}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) @@ -46,7 +48,7 @@ def test_username_token_fail(client: TestClient): def test_raw_token_fail(client: TestClient): response = client.post( - "/token", + "latest/token", data={"username": "cla_admin", "password": "cla_admin"}, headers={"Content-Type": "raw"}, ) @@ -55,7 +57,7 @@ def test_raw_token_fail(client: TestClient): def test_credential_exception(client: TestClient, auth_token): response = client.get( - "/cases/", headers={"Authorization": f"Bearer {auth_token} + 1"} + "latest/cases/", headers={"Authorization": f"Bearer {auth_token} + 1"} ) json = response.json() assert json["detail"] == "Could not validate credentials" @@ -67,7 +69,9 @@ def test_credential_exception_no_user(session, client: TestClient, auth_token): user = session.get(User, username) session.delete(user) session.commit() - response = client.get("/cases/", headers={"Authorization": f"Bearer {auth_token}"}) + response = client.get( + "latest/cases/", headers={"Authorization": f"Bearer {auth_token}"} + ) json = response.json() assert json["detail"] == "Could not validate credentials" assert response.status_code == 401 @@ -119,19 +123,19 @@ def test_token_defined_expiry(): def test_scopes_missing_scopes(client: TestClient, session: Session): # Create the test user with no given scopes # They should not be able to access the GET /cases resource as that requires the UserScopes.READ scope - assert_user_scope(session, client, [], "/cases", 401) + assert_user_scope(session, client, [], "latest/cases", 401) def test_scopes_incorrect_scope(client: TestClient, session: Session): # Create the test user with a UserScopes.CREATE scope # They should not be able to access the GET /cases resource as that requires the UserScopes.READ scope - assert_user_scope(session, client, [UserScopes.CREATE], "/cases", 401) + assert_user_scope(session, client, [UserScopes.CREATE], "latest/cases", 401) def test_scopes_correct_scope(client: TestClient, session: Session): # Create the test user with a UserScopes.READ scope # They should be able to access the GET /cases resource as that requires the UserScopes.READ scope - assert_user_scope(session, client, [UserScopes.READ], "/cases", 200) + assert_user_scope(session, client, [UserScopes.READ], "latest/cases", 200) def assert_user_scope( @@ -152,7 +156,7 @@ def assert_user_scope( # Obtain an access token for the test user response = client.post( - "/token", + "latest/token", data={"username": username, "password": password}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) diff --git a/tests/case_adaptations/test_case_adaptations.py b/tests/case_adaptations/test_case_adaptations.py index 25959f97..ed05c5e9 100644 --- a/tests/case_adaptations/test_case_adaptations.py +++ b/tests/case_adaptations/test_case_adaptations.py @@ -38,7 +38,7 @@ def test_cascade_delete(session: Session): def test_request_create_case_with_adaptations(client_authed: TestClient): """Test creating a case with adaptations through the api.""" case_data = get_case_test_data() - response = client_authed.post("/cases/", json=case_data) + response = client_authed.post("latest/cases/", json=case_data) assert response.status_code == 201 assert_dicts_equal( response.json()["case_adaptations"], case_data["case_adaptations"] @@ -54,7 +54,7 @@ def test_request_update_case_with_adaptations( session.commit() adaptations_data = {"case_adaptations": get_case_test_data()["case_adaptations"]} - response = client_authed.put(f"/cases/{case.id}", json=adaptations_data) + response = client_authed.put(f"latest/cases/{case.id}", json=adaptations_data) assert response.status_code == 200 assert_dicts_equal( response.json()["case_adaptations"], adaptations_data["case_adaptations"] @@ -66,7 +66,7 @@ def test_request_create_case_without_adaptations(client_authed: TestClient): case_data = get_case_test_data() del case_data["case_adaptations"] - response = client_authed.post("/cases/", json=case_data) + response = client_authed.post("latest/cases/", json=case_data) assert response.status_code == 201 assert response.json()["case_adaptations"] is None @@ -81,6 +81,6 @@ def test_request_update_case_without_adaptations( case_data = get_case_test_data() del case_data["case_adaptations"] - response = client_authed.put(f"/cases/{case.id}", json=case_data) + response = client_authed.put(f"latest/cases/{case.id}", json=case_data) assert response.status_code == 200 assert response.json()["case_adaptations"] is None diff --git a/tests/cases/test_case_request.py b/tests/cases/test_case_request.py index 01b7278f..430d742e 100644 --- a/tests/cases/test_case_request.py +++ b/tests/cases/test_case_request.py @@ -13,14 +13,14 @@ def test_case_create_request(client_authed: TestClient, session: Session): test_data = get_case_test_data() - response_json = client_authed.post("/cases", json=test_data).json() + response_json = client_authed.post("latest/cases", json=test_data).json() assert_dicts_equal(response_json, test_data) def test_case_create_request_minimal(client_authed: TestClient, session: Session): """Test the minimum data required to create a case.""" test_data = {"case_type": "Check if your client qualifies for legal aid"} - response_json = client_authed.post("/cases", json=test_data).json() + response_json = client_authed.post("latest/cases", json=test_data).json() expected_data = { **test_data, @@ -40,7 +40,7 @@ def test_case_create_request_not_enough_data( ): """Test that we cannot create a without providing the minimum data required.""" test_data = {} - response = client_authed.post("/cases", json=test_data) + response = client_authed.post("latest/cases", json=test_data) assert response.status_code == 422 assert response.reason_phrase == "Unprocessable Entity" @@ -69,7 +69,7 @@ def test_case_update_request(client_authed: TestClient, session: Session): ], } - response = client_authed.put(f"/cases/{original_case.id}", json=test_data) + response = client_authed.put(f"latest/cases/{original_case.id}", json=test_data) updated_case = session.get(Case, original_case.id) assert response.status_code == 200 assert updated_case.case_type == "Civil Legal Advice" @@ -113,7 +113,7 @@ def test_case_update_existing_request(client_authed: TestClient, session: Sessio ], } - response = client_authed.put(f"/cases/{original_case.id}", json=test_data) + response = client_authed.put(f"latest/cases/{original_case.id}", json=test_data) updated_case = session.get(Case, original_case.id) assert response.status_code == 200 assert updated_case.people[0].updated_at > original_updated_at @@ -137,5 +137,5 @@ def test_case_update_invalid_id_request(client_authed: TestClient, session: Sess ], } - response = client_authed.put(f"/cases/{case.id}", json=test_data) + response = client_authed.put(f"latest/cases/{case.id}", json=test_data) assert response.status_code == 404 diff --git a/tests/conftest.py b/tests/conftest.py index c4a1868e..c3c05e27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,7 +65,7 @@ def get_session_override(): def auth_token(client): # Send POST request with x-www-form-urlencoded data response = client.post( - "/token", + "latest/token", data={"username": "cla_admin", "password": "cla_admin"}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) @@ -85,7 +85,7 @@ def client_authed(auth_token, client: TestClient, session: Session): def auth_token_disabled_user(client): # Send POST request with x-www-form-urlencoded data response = client.post( - "/token", + "latest/token", data={"username": "jane_doe", "password": "password"}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) From 6aefecbe052c4f02d62306af0429d5a25c6734ca Mon Sep 17 00:00:00 2001 From: Kyle O'Brien <65071578+TawneeOwl@users.noreply.github.com> Date: Tue, 24 Dec 2024 09:33:17 +0000 Subject: [PATCH 2/3] Update documentation --- .../documentation/versioning.html.md.erb | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/source/documentation/versioning.html.md.erb diff --git a/docs/source/documentation/versioning.html.md.erb b/docs/source/documentation/versioning.html.md.erb new file mode 100644 index 00000000..f3f6d699 --- /dev/null +++ b/docs/source/documentation/versioning.html.md.erb @@ -0,0 +1,68 @@ +--- +title: API Versioning +--- + +# API Versioning +Version control helps manage changes to an API without breaking existing integrations or disrupting users relying on older versions. +Version control in our API is done using URL versioning. + +## URL Versioning +URL versioning involves specifying the version number in the endpoint url path. +For example: +``` +/latest/cases +/v1/cases/{case_id} +``` + +All versioning is handled via the external python package fastapi-versionizer ```https://pypi.org/project/fastapi-versionizer/```. +This handles all additional changes to routes. By default, fastapi-versionizer creates version 1 and adds all endpoints to it. + +### Updating the version +To add a new version, add the @api_version to the router. This will update all routers with the new version and +update the latest to this version. For example: + +``` +from fastapi_versionizer.versionizer import api_version + +@api_version(2) +@router.get( + "/{case_id}", + tags=["cases"], + response_model=CaseResponse, + dependencies=[Security(get_current_active_user, scopes=[UserScopes.READ])], +) +``` + +### Removing an endpoint +To remove an endpoint, define the version the endpoint is no longer available in: + +``` +from fastapi_versionizer.versionizer import api_version + +@api_version(1, remove_in_major=2) +@router.get( + "/{case_id}", + tags=["cases"], + response_model=CaseResponse, + dependencies=[Security(get_current_active_user, scopes=[UserScopes.READ])], +) +``` + +The above example will remove the endpoint in version 2. This means that /v1/cases/{case_id} will still be available. + +### Deprecating an endpoint +To deprecate an endpoint, add the following to the router: + +``` +@router.get( + "/{case_id}", + tags=["cases"], + response_model=CaseResponse, + dependencies=[Security(get_current_active_user, scopes=[UserScopes.READ])], + deprecated=True, +) +``` + +### Documentation +All swaggerdocs in relation to the version can be seen by using the version suffix in the url, for example +```http://0.0.0.0:8027/v1/``` or ```http://0.0.0.0:8027/latest/```. \ No newline at end of file From 52534750f7cef81ad6ea7da029282eb9dfa77ac2 Mon Sep 17 00:00:00 2001 From: Kyle O'Brien <65071578+TawneeOwl@users.noreply.github.com> Date: Tue, 24 Dec 2024 09:35:46 +0000 Subject: [PATCH 3/3] Linting --- app/routers/case_information.py | 1 + app/routers/security.py | 1 + 2 files changed, 2 insertions(+) diff --git a/app/routers/case_information.py b/app/routers/case_information.py index 9a31909e..b1786a16 100644 --- a/app/routers/case_information.py +++ b/app/routers/case_information.py @@ -13,6 +13,7 @@ from app.auth.security import get_current_active_user from app.models.users import UserScopes + logger = structlog.getLogger(__name__) diff --git a/app/routers/security.py b/app/routers/security.py index 4d7b4452..a099906f 100644 --- a/app/routers/security.py +++ b/app/routers/security.py @@ -13,6 +13,7 @@ from app.db import get_session + router = APIRouter( responses={404: {"description": "Not found"}}, )