diff --git a/src/c3nav/api/urls.py b/src/c3nav/api/urls.py index fa4469fe..f20dc60a 100644 --- a/src/c3nav/api/urls.py +++ b/src/c3nav/api/urls.py @@ -4,6 +4,7 @@ from collections import OrderedDict from django.urls import include, path, re_path from django.utils.functional import cached_property +from ninja import NinjaAPI from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.routers import SimpleRouter @@ -18,8 +19,16 @@ from c3nav.mapdata.api import (AccessRestrictionGroupViewSet, AccessRestrictionV SpaceViewSet, StairViewSet, UpdatesViewSet) from c3nav.mapdata.utils.user import can_access_editor from c3nav.mesh.api import FirmwareViewSet +from c3nav.mesh.newapi import api_router as mesh_api_router from c3nav.routing.api import RoutingViewSet +ninja_api = NinjaAPI( + title="c3nav API", + version="v2", + docs_url="/", +) +ninja_api.add_router("/mesh/", mesh_api_router) + router = SimpleRouter() router.register(r'map', MapViewSet, basename='map') router.register(r'levels', LevelViewSet) @@ -63,6 +72,8 @@ class APIRoot(GenericAPIView): The HTML preview is only shown because your Browser sent text/html in its Accept header. If you want to use this API on a large scale, please use a client that supports E-Tags. For more information on a specific API endpoint, access it with a browser. + + This is the old API which is slowly being phased out in favor of the new API at /api/v2/. """ def _format_pattern(self, pattern): diff --git a/src/c3nav/api/utils.py b/src/c3nav/api/utils.py index bcdc9f76..08ed8fef 100644 --- a/src/c3nav/api/utils.py +++ b/src/c3nav/api/utils.py @@ -1,3 +1,7 @@ +from enum import EnumMeta +from typing import Any, Optional, cast, Iterator, Callable + +from pydantic.fields import ModelField from rest_framework.exceptions import ParseError @@ -10,3 +14,26 @@ def get_api_post_data(request): raise ParseError('Invalid JSON.') return data return request.POST + + +class EnumSchemaByNameMixin: + @classmethod + def __modify_schema__(cls, field_schema: dict[str, Any], field: Optional[ModelField]) -> None: + if field is None: + return + field_schema["enum"] = list(cast(EnumMeta, field.type_).__members__.keys()) + + @classmethod + def _validate(cls, v: Any, field: ModelField) -> Any: + if isinstance(v, cls): + # it's already the object, so it's going to json, return string + return v.name + try: + # it's a string, so it's coming from json, return object + return cls[v] + except KeyError: + raise ValueError(f"Invalid value for {cls}: `{v}`") + + @classmethod + def __get_validators__(cls) -> Iterator[Callable[..., Any]]: + yield cls._validate diff --git a/src/c3nav/mesh/dataformats.py b/src/c3nav/mesh/dataformats.py index d47ae897..8ed2ec4c 100644 --- a/src/c3nav/mesh/dataformats.py +++ b/src/c3nav/mesh/dataformats.py @@ -2,6 +2,7 @@ import re from dataclasses import dataclass, field from enum import IntEnum, unique +from c3nav.api.utils import EnumSchemaByNameMixin from c3nav.mesh.baseformats import (BoolFormat, EnumFormat, FixedHexFormat, FixedStrFormat, SimpleFormat, StructType, VarArrayFormat) @@ -76,7 +77,7 @@ class UWBConfig(StructType): @unique -class BoardType(IntEnum): +class BoardType(EnumSchemaByNameMixin, IntEnum): CUSTOM = 0x00 # devboards diff --git a/src/c3nav/mesh/messages.py b/src/c3nav/mesh/messages.py index a25c2712..92f74ceb 100644 --- a/src/c3nav/mesh/messages.py +++ b/src/c3nav/mesh/messages.py @@ -5,6 +5,7 @@ from typing import TypeVar import channels from channels.db import database_sync_to_async +from c3nav.api.utils import EnumSchemaByNameMixin from c3nav.mesh.baseformats import (BoolFormat, EnumFormat, FixedStrFormat, SimpleFormat, StructType, VarArrayFormat, VarBytesFormat, VarStrFormat, normalize_name) from c3nav.mesh.dataformats import (BoardConfig, FirmwareAppDescription, MacAddressesListFormat, MacAddressFormat, @@ -68,7 +69,7 @@ M = TypeVar('M', bound='MeshMessage') @unique -class ChipType(IntEnum): +class ChipType(EnumSchemaByNameMixin, IntEnum): ESP32_S2 = 2 ESP32_C3 = 5 diff --git a/src/c3nav/mesh/models.py b/src/c3nav/mesh/models.py index 241fbcec..2cad8b99 100644 --- a/src/c3nav/mesh/models.py +++ b/src/c3nav/mesh/models.py @@ -385,7 +385,7 @@ class FirmwareBuild(models.Model): 'chip': ChipType(self.chip).name, 'sha256_hash': self.sha256_hash, 'url': self.binary.url, - 'boards': self.boards, + 'boards': [board.name for board in self.boards], } @cached_property diff --git a/src/c3nav/mesh/newapi.py b/src/c3nav/mesh/newapi.py new file mode 100644 index 00000000..f09202a2 --- /dev/null +++ b/src/c3nav/mesh/newapi.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from ninja import Router as APIRouter, Field as APIField, Schema +from ninja.pagination import paginate + +from c3nav.mesh.dataformats import BoardType +from c3nav.mesh.messages import ChipType +from c3nav.mesh.models import FirmwareVersion + +api_router = APIRouter(tags=["mesh"]) + + +class FirmwareBuildSchema(Schema): + id: int + chip: ChipType = APIField(..., example=ChipType.ESP32_C3.name) + sha256_hash: str = APIField(..., regex=r"^[0-9a-f]{64}$") + url: str = APIField(..., alias="binary", example="/media/firmware/012345/firmware.bin") + boards: list[BoardType] = APIField(..., example=[BoardType.C3NAV_LOCATION_PCB_REV_0_2.name, ]) + + @staticmethod + def resolve_chip(obj): + # todo: do this in model? idk + return ChipType(obj.chip) + + +class FirmwareSchema(Schema): + id: int + project_name: str = APIField(..., example="c3nav_positioning") + version: str = APIField(..., example="499837d-dirty") + idf_version: str = APIField(..., example="v5.1-476-g3187b8b326") + created: datetime + builds: list[FirmwareBuildSchema] + + +class Error(Schema): + detail: str + + +@api_router.get('/firmwares/', response=list[FirmwareSchema], + summary="List available firmwares") +@paginate +def firmware_list(request): + return FirmwareVersion.objects.all() + + +@api_router.get('/firmwares/{firmware_id}/', response={200: FirmwareSchema, 404: Error}, + summary="Get specific firmware") +def firmware_detail(request, firmware_id: int): + try: + return FirmwareVersion.objects.get(id=firmware_id) + except FirmwareVersion.DoesNotExist: + return 404, {"detail": "firmware not found"} diff --git a/src/c3nav/settings.py b/src/c3nav/settings.py index 1730f65b..160035cd 100644 --- a/src/c3nav/settings.py +++ b/src/c3nav/settings.py @@ -248,6 +248,7 @@ INSTALLED_APPS = [ 'channels', 'compressor', 'bootstrap3', + 'ninja', 'c3nav.api', 'rest_framework', 'c3nav.mapdata', @@ -329,6 +330,8 @@ REST_FRAMEWORK = { ) } +NINJA_PAGINATION_CLASS = "ninja.pagination.LimitOffsetPagination" + LOCALE_PATHS = ( PROJECT_DIR / 'locale', ) diff --git a/src/c3nav/urls.py b/src/c3nav/urls.py index 4f79e131..a3c64dbd 100644 --- a/src/c3nav/urls.py +++ b/src/c3nav/urls.py @@ -15,6 +15,7 @@ import c3nav.site.urls urlpatterns = [ path('editor/', include(c3nav.editor.urls)), + path('api/v2/', c3nav.api.urls.ninja_api.urls), path('api/', include((c3nav.api.urls, 'api'), namespace='api')), path('map/', include(c3nav.mapdata.urls)), path('admin/', admin.site.urls), diff --git a/src/requirements/production.txt b/src/requirements/production.txt index 859c40fb..5a0a87ac 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -3,6 +3,7 @@ django-bootstrap3==23.1 django-compressor==4.3.1 csscompressor==0.9.5 djangorestframework==3.14.0 +django-ninja==0.22.2 django-filter==23.2 shapely==2.0.1 pybind11==2.10.4 @@ -16,4 +17,4 @@ matplotlib==3.7.1 scipy==1.10.1 django_libsass==0.9 channels==4.0.0 -daphne==4.0.0 \ No newline at end of file +daphne==4.0.0