Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,15 @@ The following software packages, or their equivalents, are expected to be instal

### Configuration

None so far!
#### Environment variables

| Variable | Default | Description |
|-------------------------|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `DYNAMODB_ENDPOINT` | `http://localhost:4566` | Endpoint for the app to access DynamoDB |
| `AWS_REGION` | `eu-west-1` | AWS Region |
| `AWS_ACCESS_KEY` | `dummy_key` | AWS Access Key |
| `AWS_SECRET_ACCESS_KEY` | `dummy_secret` | AWS Secret Access Key |
| `LOG_LEVEL` | `WARNING` | Logging level. Must be one of `DEBUG`, `INFO`, `WARNING`, `ERROR` or `CRITICAL` as per [Logging Levels](https://docs.python.org/3/library/logging.html#logging-levels) |

## Usage

Expand Down
587 changes: 295 additions & 292 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ boto3 = "^1.37.3"
botocore = "^1.37.3"
eval-type-backport = "^0.2.2"
mangum = "^0.19.0"
wireup = "^0.16.0"
wireup = "^1.0.1"
python-json-logger = "^3.3.0"

[tool.poetry.group.dev.dependencies]
Expand Down
11 changes: 5 additions & 6 deletions src/eligibility_signposting_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
from mangum.types import LambdaContext, LambdaEvent

from eligibility_signposting_api import repos, services
from eligibility_signposting_api.config import LOG_LEVEL, config, init_logging
from eligibility_signposting_api.config import config, init_logging
from eligibility_signposting_api.error_handler import handle_exception
from eligibility_signposting_api.views.eligibility import eligibility
from eligibility_signposting_api.views.hello import hello
from eligibility_signposting_api.views import eligibility, hello

init_logging()
logger = logging.getLogger(__name__)
Expand All @@ -20,7 +19,7 @@
def main() -> None: # pragma: no cover
"""Run the Flask app as a local process."""
app = create_app()
app.run(debug=LOG_LEVEL == logging.DEBUG)
app.run(debug=config()["log_level"] == logging.DEBUG)


def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover
Expand All @@ -39,8 +38,8 @@ def create_app() -> Flask:
app.register_error_handler(Exception, handle_exception)

# Set up dependency injection using wireup
container = wireup.create_container(service_modules=[services, repos], parameters=config())
wireup.integration.flask.setup(container, app, import_flask_config=True)
container = wireup.create_sync_container(service_modules=[services, repos], parameters={**config(), **app.config})
wireup.integration.flask.setup(container, app)

logger.info("app ready")
return app
Expand Down
3 changes: 3 additions & 0 deletions src/eligibility_signposting_api/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
from functools import lru_cache
from typing import Any, NewType

from pythonjsonlogger.json import JsonFormatter
Expand All @@ -12,12 +13,14 @@
AwsSecretAccessKey = NewType("AwsSecretAccessKey", str)


@lru_cache
def config() -> dict[str, Any]:
return {
"dynamodb_endpoint": URL(os.getenv("DYNAMODB_ENDPOINT", "http://localhost:4566")),
"aws_region": AwsRegion(os.getenv("AWS_REGION", "eu-west-1")),
"aws_access_key_id": AwsAccessKey(os.getenv("AWS_ACCESS_KEY", "dummy_key")),
"aws_secret_access_key": AwsSecretAccessKey(os.getenv("AWS_SECRET_ACCESS_KEY", "dummy_secret")),
"log_level": LOG_LEVEL,
}


Expand Down
4 changes: 4 additions & 0 deletions src/eligibility_signposting_api/repos/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .exceptions import NotFoundError
from .person_repo import PersonRepo

__all__ = ["NotFoundError", "PersonRepo"]
8 changes: 7 additions & 1 deletion src/eligibility_signposting_api/repos/person_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
@service(qualifier="people_table")
def people_table_factory(dynamodb_resource: Annotated[ServiceResource, Inject(qualifier="dynamodb")]) -> Any:
table = dynamodb_resource.Table("People") # type: ignore[reportAttributeAccessIssue]
logger.info("built people_table: %r", table)
logger.info("people_table %r", table, extra={"table": table})
return table


Expand All @@ -25,6 +25,12 @@ def __init__(self, people_table: Annotated[Any, Inject(qualifier="people_table")

def get_person(self, name: Name) -> Person:
dynamo_response = self.people_table.get_item(Key={"name": name})
logger.debug(
"dynamo_response %r for %s",
dynamo_response,
name,
extra={"dynamo_response": dynamo_response, "person_name": name},
)

if "Item" not in dynamo_response:
message = f"Person not found with name {name}"
Expand Down
4 changes: 2 additions & 2 deletions src/eligibility_signposting_api/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .person_services import PersonService
from .person_services import PersonService, UnknownPersonError

__all__ = ["PersonService"]
__all__ = ["PersonService", "UnknownPersonError"]
4 changes: 2 additions & 2 deletions src/eligibility_signposting_api/services/person_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
from wireup import service

from eligibility_signposting_api.model.person import Name, Nickname
from eligibility_signposting_api.repos.exceptions import NotFoundError
from eligibility_signposting_api.repos.person_repo import PersonRepo
from eligibility_signposting_api.repos import NotFoundError, PersonRepo

logger = logging.getLogger(__name__)

Expand All @@ -23,6 +22,7 @@ def get_nickname(self, name: Name | None = None) -> Nickname:
if name:
try:
person = self.person_repo.get_person(name)
logger.debug("got person %r", person, extra={"person": person, "person_name": name})
except NotFoundError as e:
raise UnknownPersonError from e
else:
Expand Down
4 changes: 4 additions & 0 deletions src/eligibility_signposting_api/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .eligibility import eligibility
from .hello import hello

__all__ = ["eligibility", "hello"]
2 changes: 1 addition & 1 deletion src/eligibility_signposting_api/views/eligibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@

@eligibility.get("/<nhs_number>")
def check_eligibility(nhs_number: NHSNumber) -> ResponseReturnValue:
logger.info("nhs_number: %s", nhs_number)
logger.debug("checking nhs_number %r", nhs_number, extra={"nhs_number": nhs_number})
return make_response({}, HTTPStatus.OK)
6 changes: 3 additions & 3 deletions src/eligibility_signposting_api/views/hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@

from flask import Blueprint, make_response
from flask.typing import ResponseReturnValue
from wireup import Injected

from eligibility_signposting_api.model.person import Name
from eligibility_signposting_api.services import PersonService
from eligibility_signposting_api.services.person_services import UnknownPersonError
from eligibility_signposting_api.services import PersonService, UnknownPersonError
from eligibility_signposting_api.views.response_models import HelloResponse, Problem

logger = logging.getLogger(__name__)
Expand All @@ -16,7 +16,7 @@

@hello.get("/")
@hello.get("/<name>")
def hello_world(person_service: PersonService, name: Name | None = None) -> ResponseReturnValue:
def hello_world(person_service: Injected[PersonService], name: Name | None = None) -> ResponseReturnValue:
try:
nickname = person_service.get_nickname(name)
hello_response = HelloResponse(status=HTTPStatus.OK, message=f"Hello {nickname}!")
Expand Down
11 changes: 7 additions & 4 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
from collections.abc import Generator
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any

import boto3
import httpx
Expand All @@ -13,19 +13,22 @@
from httpx import RequestError
from yarl import URL

if TYPE_CHECKING:
from pytest_docker.plugin import Services

logger = logging.getLogger(__name__)

AWS_REGION = "eu-west-1"


@pytest.fixture(scope="session")
def localstack(request) -> URL:
def localstack(request: pytest.FixtureRequest) -> URL:
if url := os.getenv("RUNNING_LOCALSTACK_URL", None):
logger.info("localstack already running on %s", url)
return URL(url)

docker_ip = request.getfixturevalue("docker_ip")
docker_services = request.getfixturevalue("docker_services")
docker_ip: str = request.getfixturevalue("docker_ip")
docker_services: Services = request.getfixturevalue("docker_services")

logger.info("Starting localstack")
port = docker_services.port_for("localstack", 4566)
Expand Down
6 changes: 2 additions & 4 deletions tests/unit/services/test_name_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
import pytest

from eligibility_signposting_api.model.person import Person
from eligibility_signposting_api.repos.exceptions import NotFoundError
from eligibility_signposting_api.repos.person_repo import PersonRepo
from eligibility_signposting_api.services import PersonService
from eligibility_signposting_api.services.person_services import UnknownPersonError
from eligibility_signposting_api.repos import NotFoundError, PersonRepo
from eligibility_signposting_api.services import PersonService, UnknownPersonError


def test_person_service_returns_default():
Expand Down
13 changes: 6 additions & 7 deletions tests/unit/views/test_hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
from flask import Flask
from flask.testing import FlaskClient
from hamcrest import assert_that, contains_string
from wireup.integration.flask import get_container
from wireup.integration.flask import get_app_container

from eligibility_signposting_api.services import PersonService
from eligibility_signposting_api.services.person_services import UnknownPersonError
from eligibility_signposting_api.services import PersonService, UnknownPersonError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -38,24 +37,24 @@ def get_nickname(self, _: str | None = None) -> str:


def test_name_given(app: Flask, client: FlaskClient):
with get_container(app).override.service(PersonService, new=FakePersonService()):
with get_app_container(app).override.service(PersonService, new=FakePersonService()):
response = client.get("/hello/simon")
assert_that(response, is_response().with_status_code(HTTPStatus.OK).and_text(contains_string("SIMON")))


def test_default_name(app: Flask, client: FlaskClient):
with get_container(app).override.service(PersonService, new=FakePersonService()):
with get_app_container(app).override.service(PersonService, new=FakePersonService()):
response = client.get("/hello/")
assert_that(response, is_response().with_status_code(HTTPStatus.OK).and_text(contains_string("Default")))


def test_unknown_name(app: Flask, client: FlaskClient):
with get_container(app).override.service(PersonService, new=FakeUnknownPersonService()):
with get_app_container(app).override.service(PersonService, new=FakeUnknownPersonService()):
response = client.get("/hello/fred")
assert_that(response, is_response().with_status_code(HTTPStatus.NOT_FOUND))


def test_unexpected_error(app: Flask, client: FlaskClient):
with get_container(app).override.service(PersonService, new=FakeUnexpectedErrorPersonService()):
with get_app_container(app).override.service(PersonService, new=FakeUnexpectedErrorPersonService()):
response = client.get("/hello/fred")
assert_that(response, is_response().with_status_code(HTTPStatus.INTERNAL_SERVER_ERROR))
Loading