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
4 changes: 2 additions & 2 deletions docker-compose.debugger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
services:
db:
container_name: mapdb-postgres-debugger
image: postgres
image: postgres:18
volumes:
- ${POSTGRES_DATA_DIR}/debugger-db/:/var/lib/postgresql/data
- ${POSTGRES_DATA_DIR}/debugger-db/:/var/lib/postgresql
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
services:
db:
container_name: mapdb-postgres
image: postgres
image: postgres:18
volumes:
- ${POSTGRES_DATA_DIR}:/var/lib/postgresql/data
- ${POSTGRES_DATA_DIR}:/var/lib/postgresql
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
services:
db:
container_name: mapdb-postgres-dev
image: postgres
image: postgres:18
volumes:
- ${POSTGRES_DATA_DIR}:/var/lib/postgresql/data
- ${POSTGRES_DATA_DIR}:/var/lib/postgresql
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
Expand Down
11 changes: 1 addition & 10 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,29 @@ FROM python:3.12-bookworm AS base
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Configurable user setup
ENV USER=cncnet
ENV UID=1000
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was causing issues on machines where the user ID of the host didn't match


WORKDIR /cncnet-map-api

# Install system dependencies
RUN apt-get update && apt-get install -y liblzo2-dev libmagic1

# Create non-root user with configurable name and UID
RUN useradd -m -u ${UID} ${USER}

# Copy necessary files for the build
COPY requirements.txt /cncnet-map-api
COPY requirements-dev.txt /cncnet-map-api
COPY web_entry_point.sh /cncnet-map-api

# Set permissions and make script executable
RUN chmod +x /cncnet-map-api/web_entry_point.sh && \
chown -R ${USER}:${USER} /cncnet-map-api
RUN chmod +x /cncnet-map-api/web_entry_point.sh

RUN pip install --upgrade pip

FROM base AS dev
RUN pip install -r ./requirements-dev.txt
USER ${USER}
ENTRYPOINT ["/cncnet-map-api/web_entry_point.sh"]

FROM base AS prod
COPY . /cncnet-map-api
RUN pip install -r ./requirements.txt
USER ${USER}
ENTRYPOINT ["/cncnet-map-api/web_entry_point.sh"]

FROM base AS debugger
Expand Down
3 changes: 3 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ http {
alias /usr/share/nginx/html/static/; # The nginx container's mounted volume.
expires 30d;
add_header Cache-Control public;
include /etc/nginx/mime.types;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needed to serve files

}

# Serve user uploaded files
location /silo/ {
alias /usr/share/nginx/html/silo/; # The container's mounted volume.
include /etc/nginx/mime.types;
}

# Proxy requests to the Django app running in gunicorn
Expand All @@ -29,6 +31,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
}
}
}
58 changes: 58 additions & 0 deletions docs/python/endpoint-style-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# API Errors in API class helpers

Returning errors in the helper functions of your API endpoint can be annoying.
To avoid that annoyance, just raise one of the [view exceptions](kirovy/exceptions/view_exceptions.py)
or write your own that subclasses `KirovyValidationError`.

**Example where you annoy yourself with bubbling returns:**

```python
class MyView(APIView):
...
def helper(self, request: KirovyRequest) -> MyObject | KirovyResponse:
object_id = request.data.get("id")
if not object_id:
return KirovyResponse(
status=status.HTTP_400_BAD_REQUEST,
data=ErrorResponseData(
message="Must specify id",
code=api_codes.FileUploadApiCodes.MISSING_FOREIGN_ID,
additional={"expected_field": "id"}
)
)

object = get_object_or_404(self.file_parent_class.objects, id=object_id)
self.check_object_permissions(request, object)

return object

def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
object = self.helper(request)
if isinstance(object, KirovyResponse):
return object
...
```

**Example where you just raise the exception:**

```python
class MyView(APIView):
...
def helper(self, request: KirovyRequest) -> MyObject:
object_id = request.data.get("id")
if not object_id:
raise KirovyValidationError(
detail="Must specify id",
code=api_codes.FileUploadApiCodes.MISSING_FOREIGN_ID,
additional={"expected_field": "id"}
)

object = get_object_or_404(self.file_parent_class.objects, id=object_id)
self.check_object_permissions(request, object)

return object

def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
object = self.helper(request)
...
```
16 changes: 16 additions & 0 deletions kirovy/constants/api_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,19 @@ class LegacyUploadApiCodes(enum.StrEnum):
INVALID_FILE_TYPE = "invalid-file-type-in-zip"
GAME_NOT_SUPPORTED = "game-not-supported"
MAP_FAILED_TO_PARSE = "map-failed-to-parse"


class FileUploadApiCodes(enum.StrEnum):
MISSING_FOREIGN_ID = "missing-foreign-id"
INVALID = "file-failed-validation"
UNSUPPORTED = "parent-does-not-support-this-upload"
"""attr: Raised when the parent object for the file does not allow this upload.

e.g. a temporary map does not support custom image uploads.
"""
TOO_LARGE = "file-too-large"


class GenericApiCodes(enum.StrEnum):
CANNOT_UPDATE_FIELD = "field-cannot-be-updated-after-creation"
"""attr: Some fields are not allowed to be edited via any API endpoint."""
7 changes: 3 additions & 4 deletions kirovy/exception_handler.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from rest_framework import status
from rest_framework.views import exception_handler

from kirovy.exceptions.view_exceptions import KirovyValidationError
from kirovy.exceptions.view_exceptions import KirovyAPIException
from kirovy.objects import ui_objects
from kirovy.response import KirovyResponse

Expand All @@ -23,7 +22,7 @@ def kirovy_exception_handler(exception: Exception, context) -> KirovyResponse[ui
Returns the ``KirovyResponse`` if the exception is one we defined.
Otherwise, it calls the base DRF exception handler :func:`rest_framework.views.exception_handler`.
"""
if isinstance(exception, KirovyValidationError):
return KirovyResponse(exception.as_error_response_data(), status=status.HTTP_400_BAD_REQUEST)
if isinstance(exception, KirovyAPIException):
return KirovyResponse(exception.as_error_response_data(), status=exception.status_code)

return exception_handler(exception, context)
50 changes: 40 additions & 10 deletions kirovy/exceptions/view_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.utils.encoding import force_str
from rest_framework import status
from rest_framework.exceptions import APIException as _DRFAPIException
from django.utils.translation import gettext_lazy as _
Expand All @@ -6,20 +7,23 @@
from kirovy.objects import ui_objects


class KirovyValidationError(_DRFAPIException):
"""A custom exception that easily converts to the standard ``ErrorResponseData``
class KirovyAPIException(_DRFAPIException):
status_code: _t.ClassVar[int] = status.HTTP_500_INTERNAL_SERVER_ERROR
additional: _t.DictStrAny | None = None
code: str | None
"""attr: Some kind of string that the UI will recognize. e.g. ``file-too-large``.

See: :class:`kirovy.objects.ui_objects.ErrorResponseData`
Maps to the UI object attr :attr:`kirovy.objects.ui_objects.ErrorResponseData.code`.

This exception is meant to be used within serializers or views.
"""
.. warning::

status_code = status.HTTP_400_BAD_REQUEST
default_detail = _("Invalid input.")
default_code = "invalid"
additional: _t.DictStrAny | None = None
code: str | None
This is **not** the HTTP code. The HTTP code will always be ``400`` for validation errors.
"""
detail: str | None
"""attr: Extra detail in plain language. Think of this as a message for the user.

Maps to the UI object attr :attr:`kirovy.objects.ui_objects.ErrorResponseData.message`.
"""

def __init__(self, detail: str | None = None, code: str | None = None, additional: _t.DictStrAny | None = None):
super().__init__(detail=detail, code=code)
Expand All @@ -29,3 +33,29 @@ def __init__(self, detail: str | None = None, code: str | None = None, additiona

def as_error_response_data(self) -> ui_objects.ErrorResponseData:
return ui_objects.ErrorResponseData(message=self.detail, code=self.code, additional=self.additional)


class KirovyValidationError(KirovyAPIException):
"""A custom exception that easily converts to the standard ``ErrorResponseData``

See: :class:`kirovy.objects.ui_objects.ErrorResponseData`

This exception is meant to be used within serializers or views.
"""

status_code = status.HTTP_400_BAD_REQUEST
default_detail = _("Invalid input.")
default_code = "invalid"


class KirovyMethodNotAllowed(KirovyAPIException):
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
default_detail = _('Method "{method}" not allowed.')
default_code = "method_not_allowed"

def __init__(
self, method, detail: str | None = None, code: str | None = None, additional: _t.DictStrAny | None = None
):
if detail is None:
detail = force_str(self.default_detail).format(method=method)
super().__init__(detail, code, additional)
9 changes: 7 additions & 2 deletions kirovy/logging.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from structlog import get_logger
import orjson
from structlog import get_logger as _get_logger
import typing as t

from structlog.stdlib import BoundLogger


def default_json_encode_object(value: object) -> str:
json_func: t.Callable[[object], str] | None = getattr(value, "__json__", None)
Expand All @@ -13,3 +14,7 @@ def default_json_encode_object(value: object) -> str:
return str(value)

return f"cannot-json-encode--{type(value).__name__}"


def get_logger(*args: t.Any, **initial_values: t.Any) -> BoundLogger:
return _get_logger(*args, **initial_values)
40 changes: 10 additions & 30 deletions kirovy/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ class Migration(migrations.Migration):
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
models.DateTimeField(blank=True, null=True, verbose_name="last login"),
),
(
"id",
Expand All @@ -44,17 +42,13 @@ class Migration(migrations.Migration):
),
(
"username",
models.CharField(
help_text="The name from the CNCNet ladder API.", null=True
),
models.CharField(help_text="The name from the CNCNet ladder API.", null=True),
),
("verified_map_uploader", models.BooleanField(default=False)),
("verified_email", models.BooleanField(default=False)),
(
"group",
models.CharField(
help_text="The user group from the CNCNet ladder API."
),
models.CharField(help_text="The user group from the CNCNet ladder API."),
),
(
"is_banned",
Expand Down Expand Up @@ -89,9 +83,7 @@ class Migration(migrations.Migration):
),
(
"ban_count",
models.IntegerField(
default=0, help_text="How many times this user has been banned."
),
models.IntegerField(default=0, help_text="How many times this user has been banned."),
),
],
options={
Expand Down Expand Up @@ -123,9 +115,7 @@ class Migration(migrations.Migration):
("about", models.CharField(max_length=2048, null=True)),
(
"extension_type",
models.CharField(
choices=[("map", "map"), ("assets", "assets")], max_length=32
),
models.CharField(choices=[("map", "map"), ("assets", "assets")], max_length=32),
),
],
options={
Expand Down Expand Up @@ -257,9 +247,7 @@ class Migration(migrations.Migration):
("name", models.CharField(max_length=255)),
(
"file",
models.FileField(
upload_to=kirovy.models.file_base._generate_upload_to
),
models.FileField(upload_to=kirovy.models.file_base.default_generate_upload_to),
),
("hash_md5", models.CharField(max_length=32)),
("hash_sha512", models.CharField(max_length=512)),
Expand All @@ -268,15 +256,11 @@ class Migration(migrations.Migration):
("version", models.IntegerField()),
(
"cnc_game",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncgame"
),
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncgame"),
),
(
"cnc_map",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="kirovy.cncmap"
),
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="kirovy.cncmap"),
),
(
"file_extension",
Expand All @@ -295,9 +279,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="cncmap",
name="cnc_game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncgame"
),
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncgame"),
),
migrations.AddField(
model_name="cncmap",
Expand All @@ -320,9 +302,7 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name="cncmapfile",
constraint=models.UniqueConstraint(
fields=("cnc_map_id", "version"), name="unique_map_version"
),
constraint=models.UniqueConstraint(fields=("cnc_map_id", "version"), name="unique_map_version"),
),
migrations.AlterModelManagers(
name="cncuser",
Expand Down
Loading