Skip to content

Commit

Permalink
Merge pull request #70 from geoadmin/develop
Browse files Browse the repository at this point in the history
New Release v0.3.0 - #minor
  • Loading branch information
schtibe authored Jan 31, 2025
2 parents 040b9e7 + b4f493b commit e7ca36f
Show file tree
Hide file tree
Showing 23 changed files with 1,379 additions and 171 deletions.
4 changes: 4 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
[run]
omit =
*/tests/*

[report]
exclude_lines =
if TYPE_CHECKING:
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ var/
.volumes

# Coverage
.coverage
coverage.xml
htmlcov/
2 changes: 2 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ logging-utilities = "~=4.4.1"
boto3 = "~=1.35.78"
nanoid = "~=2.0.0"
whitenoise = "~=6.8.2"
pystac-client = "~=0.8.5"
ecs-logging = "*"

[dev-packages]
yapf = "*"
Expand Down
336 changes: 329 additions & 7 deletions Pipfile.lock

Large diffs are not rendered by default.

11 changes: 4 additions & 7 deletions app/config/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from logging import getLogger

from access.api import router as access_router
from botocore.exceptions import EndpointConnectionError
from config.logging import LoggedNinjaAPI
from distributions.api import router as distributions_router
from ninja import NinjaAPI
from ninja.errors import AuthenticationError
Expand All @@ -17,9 +16,7 @@
from django.http import HttpRequest
from django.http import HttpResponse

logger = getLogger(__name__)

api = NinjaAPI()
api = LoggedNinjaAPI()

api.add_router("", provider_router)
api.add_router("", distributions_router)
Expand All @@ -32,6 +29,7 @@ def handle_django_validation_error(
) -> HttpResponse:
"""Convert the given validation error to a response with corresponding status."""
error_code_unique_constraint_violated = "unique"

if contains_error_code(exception, error_code_unique_constraint_violated):
status = 409
else:
Expand Down Expand Up @@ -61,7 +59,6 @@ def handle_404_not_found(request: HttpRequest, exception: Http404) -> HttpRespon

@api.exception_handler(Exception)
def handle_exception(request: HttpRequest, exception: Exception) -> HttpResponse:
logger.exception(exception)
return api.create_response(
request,
{
Expand All @@ -84,7 +81,6 @@ def handle_http_error(request: HttpRequest, exception: HttpError) -> HttpRespons

@api.exception_handler(AuthenticationError)
def handle_unauthorized(request: HttpRequest, exception: AuthenticationError) -> HttpResponse:
logger.exception(exception)
return api.create_response(
request,
{
Expand All @@ -101,6 +97,7 @@ def handle_ninja_validation_error(
messages: list[str] = []
for error in exception.errors:
messages.extend(error.values())

return api.create_response(
request,
{
Expand Down
1 change: 1 addition & 0 deletions app/config/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from django.core.asgi import get_asgi_application

# default to the setting that's being created in DOCKERFILE
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')

application = get_asgi_application()
34 changes: 34 additions & 0 deletions app/config/logging-cfg-local.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
version: 1
disable_existing_loggers: False

root:
handlers:
- console
level: DEBUG
propagate: True

# configure loggers per app
loggers:
django.request:
# setting this to ERROR prevents from logging too much
# we do the logging ourselves
level: ERROR
# provider:
# level: DEBUG
# cognito:
# level: DEBUG
# access:
# level: DEBUG
# distributions:
# level: DEBUG

formatters:
ecs:
(): ecs_logging.StdlibFormatter

handlers:
console:
class: logging.StreamHandler
formatter: ecs
stream: ext://sys.stdout

118 changes: 118 additions & 0 deletions app/config/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import sys
from logging import getLogger
from typing import Any
from typing import List
from typing import Optional
from typing import TypedDict

from ninja import NinjaAPI

from django.conf import settings
from django.http import HttpRequest
from django.http import HttpResponse

logger = getLogger(__name__)

LogExtra = TypedDict(
'LogExtra',
{
'http': {
'request': {
'method': str, 'header': dict[str, str]
},
'response': {
'status_code': int, 'header': dict[str, str]
}
},
'url': {
'path': str, 'scheme': str
}
}
)


def generate_log_extra(request: HttpRequest, response: HttpResponse) -> LogExtra:
"""Generate the extra dict for the logging calls
This will format the following extra fields to be sent to the logger:
request:
http:
request:
method: GET | POST | PUT | ...
header: LIST OF HEADERS
response:
header: LIST OF HEADERS
status_code: STATUS_CODE
url:
path: REQUEST_PATH
scheme: REQUEST_SCHEME
Args:
request (HttpRequest): Request object
response (HttpResponse): Response object
Returns:
dict: dict of extras
"""
return {
'http': {
'request': {
'method': request.method or 'UNKNOWN',
'header': {
k.lower(): v for k,
v in request.headers.items() if k.lower() in settings.LOG_ALLOWED_HEADERS
}
},
'response': {
'status_code': response.status_code,
'header': {
k.lower(): v for k,
v in response.headers.items() if k.lower() in settings.LOG_ALLOWED_HEADERS
},
}
},
'url': {
'path': request.path or 'UNKNOWN', 'scheme': request.scheme or 'UNKNOWN'
}
}


class LoggedNinjaAPI(NinjaAPI):
"""Extension for the NinjaAPI to log the requests to elastic
Overwriting the method that creates a response. The only thing done then
is that depending on the status, a log entry will be triggered.
"""

def create_response(
self,
request: HttpRequest,
data: Any,
*args: List[Any],
status: Optional[int] = None,
temporal_response: Optional[HttpResponse] = None,
) -> HttpResponse:
response = super().create_response(
request, data, *args, status=status, temporal_response=temporal_response
)

if response.status_code >= 200 and response.status_code < 400:
logger.info(
"Response %s on %s",
response.status_code, # parameter for %s
request.path, # parameter for %s
extra=generate_log_extra(request, response)
)
elif response.status_code >= 400 and response.status_code < 500:
logger.warning(
"Response %s on %s",
response.status_code, # parameter for %s
request.path, # parameter for %s
extra=generate_log_extra(request, response)
)
else:
logger.exception(repr(sys.exc_info()[1]), extra=generate_log_extra(request, response))

return response
70 changes: 70 additions & 0 deletions app/config/settings_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
https://docs.djangoproject.com/en/5.0/ref/settings/
"""

import os
from pathlib import Path

import environ
import yaml

env = environ.Env()

Expand Down Expand Up @@ -163,3 +165,71 @@
# nanoid
SHORT_ID_SIZE = env.int('SHORT_ID_SIZE', 12)
SHORT_ID_ALPHABET = env.str('SHORT_ID_ALPHABET', '0123456789abcdefghijklmnopqrstuvwxyz')


# Read configuration from file
def get_logging_config() -> dict[str, object]:
'''Read logging configuration
Read and parse the yaml logging configuration file passed in the environment variable
LOGGING_CFG and return it as dictionary
Note: LOGGING_CFG is relative to the root of the repo
'''
log_config_file = env('LOGGING_CFG', default='config/logging-cfg-local.yaml')
if log_config_file.lower() in ['none', '0', '', 'false', 'no']:
return {}
log_config = {}
with open(BASE_DIR / log_config_file, 'rt', encoding="utf-8") as fd:
log_config = yaml.safe_load(os.path.expandvars(fd.read()))
return log_config


LOGGING = get_logging_config()

# list of headers that are allowed to be logged
_DEFAULT_LOG_ALLOWED_HEADERS = [

# Standard headers
"accept",
"accept-encoding",
"accept-language",
"accept-ranges",
"cache-control",
"connection",
"content-length",
"content-security-policy",
"content-type",
"etag",
"host",
"if-match",
"if-none-match",
"origin",
"referer",
"referrer-policy",
"transfer-encoding",
"user-agent",
"vary",
"x-content-type-options",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",

# Cloudfront headers
"cloudfront-is-android-viewer",
"cloudfront-is-desktop-viewer",
"cloudfront-is-ios-viewer",
"cloudfront-is-mobile-viewer",
"cloudfront-is-smarttv-viewer",
"cloudfront-is-tablet-viewer",

# PPBGDI headers
"x-e2e-testing",
# API GW Headers
"geoadmin-authenticated"
"geoadmin-username",
"apigw-requestid"
]
LOG_ALLOWED_HEADERS = [
str(header).lower()
for header in env.list('LOG_ALLOWED_HEADERS', default=_DEFAULT_LOG_ALLOWED_HEADERS)
]
21 changes: 0 additions & 21 deletions app/config/settings_prod.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,7 @@
import os

import yaml

from .settings_base import * # pylint: disable=wildcard-import, unused-wildcard-import

DEBUG = False


# Read configuration from file
def get_logging_config() -> dict[str, object]:
'''Read logging configuration
Read and parse the yaml logging configuration file passed in the environment variable
LOGGING_CFG and return it as dictionary
Note: LOGGING_CFG is relative to the root of the repo
'''
log_config_file = env('LOGGING_CFG', default='app/config/logging-cfg-local.yml')
if log_config_file.lower() in ['none', '0', '', 'false', 'no']:
return {}
log_config = {}
with open(BASE_DIR / log_config_file, 'rt', encoding="utf-8") as fd:
log_config = yaml.safe_load(os.path.expandvars(fd.read()))
return log_config


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/

Expand Down
2 changes: 1 addition & 1 deletion app/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@

urlpatterns = [
path('', root.urls),
path('api/', api.urls),
path('api/v1/', api.urls),
path('admin/', admin.site.urls),
]
Empty file.
Loading

0 comments on commit e7ca36f

Please sign in to comment.