Skip to content

Commit

Permalink
Merge pull request #74 from ministryofjustice/feature/LGA-2816-fast-a…
Browse files Browse the repository at this point in the history
…pi-versioning

Feature/lga 2816 fast api versioning
  • Loading branch information
TawneeOwl authored Jan 7, 2025
2 parents ed8d89e + 5253475 commit 7431c9d
Show file tree
Hide file tree
Showing 10 changed files with 109 additions and 22 deletions.
8 changes: 8 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
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:
app = FastAPI(**docs_config)
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
68 changes: 68 additions & 0 deletions docs/source/documentation/versioning.html.md.erb
Original file line number Diff line number Diff line change
@@ -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/```.
2 changes: 2 additions & 0 deletions requirements/generated/requirements-development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions requirements/generated/requirements-production.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions requirements/generated/requirements-testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements/source/requirements-base.in
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ argon2_cffi
structlog
typer
tabulate==0.9.0
fastapi-versionizer==4.0.1
24 changes: 14 additions & 10 deletions tests/auth/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@


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


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"
Expand All @@ -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"},
)
Expand All @@ -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"},
)
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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"},
)
Expand Down
8 changes: 4 additions & 4 deletions tests/case_adaptations/test_case_adaptations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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"]
Expand All @@ -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

Expand All @@ -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
12 changes: 6 additions & 6 deletions tests/cases/test_case_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
)
Expand All @@ -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"},
)
Expand Down

0 comments on commit 7431c9d

Please sign in to comment.