From 0f43274e335cd1b88f24c143a70d4aabe29b29c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Kl=C3=BCnder?= Date: Sun, 19 Nov 2023 00:12:10 +0100 Subject: [PATCH] some more documentation in the API and in the API code --- src/c3nav/api/newapi.py | 7 ++- src/c3nav/api/newauth.py | 15 ++++++- src/c3nav/api/schema.py | 9 +++- src/c3nav/api/urls.py | 41 ++++++++++++++++-- src/c3nav/mapdata/schemas/model_base.py | 6 ++- src/c3nav/mapdata/schemas/models.py | 7 ++- src/c3nav/mapdata/schemas/responses.py | 8 +++- src/c3nav/mesh/newapi.py | 57 +++++++++++++++++++------ 8 files changed, 126 insertions(+), 24 deletions(-) diff --git a/src/c3nav/api/newapi.py b/src/c3nav/api/newapi.py index a8a4124d..d0782a03 100644 --- a/src/c3nav/api/newapi.py +++ b/src/c3nav/api/newapi.py @@ -2,11 +2,16 @@ from django.conf import settings from ninja import Router as APIRouter from ninja import Schema +from c3nav.api.utils import NonEmptyStr + auth_api_router = APIRouter(tags=["auth"]) class APITokenSchema(Schema): - token: str + """ + An API token to be used with Bearer authentication + """ + token: NonEmptyStr @auth_api_router.get('/session/', response=APITokenSchema, auth=None, diff --git a/src/c3nav/api/newauth.py b/src/c3nav/api/newauth.py index 6c04692b..687de700 100644 --- a/src/c3nav/api/newauth.py +++ b/src/c3nav/api/newauth.py @@ -14,7 +14,20 @@ from c3nav.control.models import UserPermissions FakeRequest = namedtuple('FakeRequest', ('session', )) -class BearerAuth(HttpBearer): +description = """ +An API token can be acquired in 4 ways: + +* Use `anonymous` for guest access. +* Generate a session-bound token using the auth session endpoint. +* Create an API token in your user account settings. +* Create an API token by signing in through the auth endpoint. +""".strip() + + +class APITokenAuth(HttpBearer): + openapi_name = "api token authentication" + openapi_description = description + def __init__(self, logged_in=False, superuser=False): super().__init__() self.logged_in = superuser or logged_in diff --git a/src/c3nav/api/schema.py b/src/c3nav/api/schema.py index e6e7ca2c..44852bae 100644 --- a/src/c3nav/api/schema.py +++ b/src/c3nav/api/schema.py @@ -3,9 +3,16 @@ from typing import Literal from ninja import Schema from pydantic import Field as APIField +from c3nav.api.utils import NonEmptyStr + class APIErrorSchema(Schema): - detail: str + """ + An error has occured with this request + """ + detail: NonEmptyStr = APIField( + description="A human-readable error description" + ) class PolygonSchema(Schema): diff --git a/src/c3nav/api/urls.py b/src/c3nav/api/urls.py index 56d1d879..d058649a 100644 --- a/src/c3nav/api/urls.py +++ b/src/c3nav/api/urls.py @@ -5,7 +5,7 @@ from collections import OrderedDict from django.conf import settings from django.urls import include, path, re_path from django.utils.functional import cached_property -from ninja import NinjaAPI +from ninja import NinjaAPI, Swagger from ninja.schema import NinjaGenerateJsonSchema from rest_framework.generics import GenericAPIView from rest_framework.response import Response @@ -14,7 +14,7 @@ from rest_framework.routers import SimpleRouter from c3nav.api.api import SessionViewSet from c3nav.api.exceptions import CustomAPIException from c3nav.api.newapi import auth_api_router -from c3nav.api.newauth import BearerAuth +from c3nav.api.newauth import APITokenAuth from c3nav.editor.api import ChangeSetViewSet, EditorViewSet from c3nav.mapdata.api import (AccessRestrictionGroupViewSet, AccessRestrictionViewSet, AreaViewSet, BuildingViewSet, ColumnViewSet, CrossDescriptionViewSet, DoorViewSet, DynamicLocationPositionViewSet, @@ -28,11 +28,42 @@ from c3nav.mesh.api import FirmwareViewSet from c3nav.mesh.newapi import mesh_api_router from c3nav.routing.api import RoutingViewSet +description = """ +Nearly all endpoints require authentication, but guest authentication can be used. + +API endpoints may change to add more features and properties, +but no properties will be removed without a version change. +""".strip() + ninja_api = NinjaAPI( title="c3nav API", version="v2", + description=description, + docs_url="/", - auth=(None if settings.DEBUG else BearerAuth()), + docs=Swagger(settings={ + "persistAuthorization": True, + "defaultModelRendering": "model", + }), + + auth=APITokenAuth(), + + openapi_extra={ + "tags": [ + { + "name": "auth", + "description": "Get and manage API access", + }, + { + "name": "map", + "description": "Access the map data", + }, + { + "name": "mesh", + "description": "Manage the location node mesh network", + }, + ], + } ) @@ -41,7 +72,9 @@ def on_invalid_token(request, exc): return ninja_api.create_response(request, {"detail": exc.detail}, status=exc.status_code) -# ugly hack: remove schema from the end of definition names +""" +ugly hack: remove schema from the end of definition names +""" orig_normalize_name = NinjaGenerateJsonSchema.normalize_name def wrap_normalize_name(self, name: str): # noqa return orig_normalize_name(self, name).removesuffix('Schema') diff --git a/src/c3nav/mapdata/schemas/model_base.py b/src/c3nav/mapdata/schemas/model_base.py index 07f5911b..11edcaf2 100644 --- a/src/c3nav/mapdata/schemas/model_base.py +++ b/src/c3nav/mapdata/schemas/model_base.py @@ -77,7 +77,11 @@ class LocationSchema(AccessRestrictionSchema, TitledSchema, LocationSlugSchema, # todo: add_search -class LabelSettingsSchema(TitledSchema, Schema): +class LabelSettingsSchema(TitledSchema, DjangoModelSchema): + """ + Settings preset for how and when to display a label. Reusable between objects. + The title describes the title of this preset, not the displayed label. + """ min_zoom: float = APIField( title="min zoom", ) diff --git a/src/c3nav/mapdata/schemas/models.py b/src/c3nav/mapdata/schemas/models.py index d511948e..22d7ee37 100644 --- a/src/c3nav/mapdata/schemas/models.py +++ b/src/c3nav/mapdata/schemas/models.py @@ -1,6 +1,6 @@ from typing import Optional -from ninja import Schema +from ninja import Schema, Swagger from pydantic import Field as APIField from pydantic import PositiveFloat, PositiveInt @@ -9,6 +9,11 @@ from c3nav.mapdata.schemas.model_base import SpecificLocationSchema class LevelSchema(SpecificLocationSchema): + """ + A physical level of the map, containing building, spaces, doors… + + A level is a specific location, and can therefor be routed to and from, as well as belong to location groups. + """ short_label: NonEmptyStr = APIField( title="short label (for level selector)", description="unique among levels", diff --git a/src/c3nav/mapdata/schemas/responses.py b/src/c3nav/mapdata/schemas/responses.py index e75c237b..24eefc4c 100644 --- a/src/c3nav/mapdata/schemas/responses.py +++ b/src/c3nav/mapdata/schemas/responses.py @@ -3,4 +3,10 @@ from pydantic import Field as APIField class BoundsSchema(Schema): - bounds: tuple[tuple[float, float], tuple[float, float]] = APIField(..., example=((-10, -20), (20, 30))) + """ + Describing a bounding box + """ + bounds: tuple[tuple[float, float], tuple[float, float]] = APIField( + description="(x, y) to (x, y)", + example=((-10, -20), (20, 30)), + ) diff --git a/src/c3nav/mesh/newapi.py b/src/c3nav/mesh/newapi.py index 2405e7cf..72e517bf 100644 --- a/src/c3nav/mesh/newapi.py +++ b/src/c3nav/mesh/newapi.py @@ -6,10 +6,11 @@ from ninja import Field as APIField from ninja import Router as APIRouter from ninja import Schema, UploadedFile from ninja.pagination import paginate +from ninja.renderers import BaseRenderer from pydantic import PositiveInt, field_validator from c3nav.api.exceptions import API404, APIConflict, APIRequestValidationFailed -from c3nav.api.newauth import BearerAuth, auth_permission_responses, auth_responses, validate_responses +from c3nav.api.newauth import APITokenAuth, auth_permission_responses, auth_responses, validate_responses from c3nav.mesh.dataformats import BoardType, ChipType, FirmwareImage from c3nav.mesh.models import FirmwareBuild, FirmwareVersion @@ -17,25 +18,47 @@ mesh_api_router = APIRouter(tags=["mesh"]) class FirmwareBuildSchema(Schema): + """ + A build belonging to a firmware version. + """ id: PositiveInt - variant: str = APIField(..., example="c3uart") - chip: ChipType = APIField(..., example=ChipType.ESP32_C3.name) - sha256_hash: str = APIField(..., pattern=r"^[0-9a-f]{64}$") - url: str = APIField(..., alias="binary", example="/media/firmware/012345/firmware.bin") # todo: downlaod differently? + variant: str = APIField( + description="a variant identifier for this build, unique for this firmware version", + example="c3uart" + ) + chip: ChipType = APIField( + description="the chip that this build was built for", + example=ChipType.ESP32_C3.name, + ) + sha256_hash: str = APIField( + description="SHE256 hash of the underlying ELF file", + pattern=r"^[0-9a-f]{64}$", + ) + url: str = APIField( + alias="binary", + example="/media/firmware/012345/firmware.bin", + description="download URL for the build binary", + ) # todo: downlaod differently? # todo: should not be none, but parse errors - boards: list[BoardType] = APIField(None, example=[BoardType.C3NAV_LOCATION_PCB_REV_0_2.name, ]) + boards: set[BoardType] = APIField( + description="set of boards that this build is compatible with", + example={BoardType.C3NAV_LOCATION_PCB_REV_0_2.name, } + ) class Config(Schema.Config): pass class FirmwareSchema(Schema): + """ + A firmware version, usually with multiple build variants. + """ id: PositiveInt 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] + builds: list[FirmwareBuildSchema] = APIField(min_items=1) @field_validator('builds') def builds_variants_must_be_unique(cls, builds): @@ -44,10 +67,6 @@ class FirmwareSchema(Schema): return builds -class Error(Schema): - detail: str - - @mesh_api_router.get('/firmwares/', summary="List available firmwares", response={200: list[FirmwareSchema], **validate_responses, **auth_responses}) @paginate @@ -66,7 +85,9 @@ def firmware_detail(request, firmware_id: int): @mesh_api_router.get('/firmwares/{firmware_id}/{variant}/image_data', summary="Get header data of firmware build image", - response={200: FirmwareImage.schema, **API404.dict(), **auth_responses}) + response={200: FirmwareImage.schema, **API404.dict(), **auth_responses}, + openapi_extra={"externalDocs": "https://docs.espressif.com/projects/esptool/en/latest/esp32s3/" + "advanced-topics/firmware-image-format.html"}) def firmware_build_image(request, firmware_id: int, variant: str): try: build = FirmwareBuild.objects.get(version_id=firmware_id, variant=variant) @@ -77,7 +98,9 @@ def firmware_build_image(request, firmware_id: int, variant: str): @mesh_api_router.get('/firmwares/{firmware_id}/{variant}/project_description', summary="Get project description of firmware build", - response={200: dict, **API404.dict(), **auth_responses}) + response={200: dict, **API404.dict(), **auth_responses}, + openapi_extra={"externalDocs": "https://docs.espressif.com/projects/esp-idf/en/latest/esp32/" + "api-guides/build-system.html#build-system-metadata"}) def firmware_project_description(request, firmware_id: int, variant: str): try: return FirmwareBuild.objects.get(version_id=firmware_id, variant=variant).firmware_description @@ -86,6 +109,9 @@ def firmware_project_description(request, firmware_id: int, variant: str): class UploadFirmwareBuildSchema(Schema): + """ + A firmware build to upload, with at least one build variant + """ variant: str = APIField(..., example="c3uart") boards: list[BoardType] = APIField(..., example=[BoardType.C3NAV_LOCATION_PCB_REV_0_2.name, ]) project_description: dict = APIField(..., title='project_description.json contents') @@ -93,10 +119,13 @@ class UploadFirmwareBuildSchema(Schema): class UploadFirmwareSchema(Schema): + """ + A firmware version to upload, with at least one build variant + """ project_name: str = APIField(..., example="c3nav_positioning") version: str = APIField(..., example="499837d-dirty") idf_version: str = APIField(..., example="v5.1-476-g3187b8b326") - builds: list[UploadFirmwareBuildSchema] = APIField(..., min_items=1) + builds: list[UploadFirmwareBuildSchema] = APIField(min_items=1) @field_validator('builds') def builds_variants_must_be_unique(cls, builds):