some more documentation in the API and in the API code

This commit is contained in:
Laura Klünder 2023-11-19 00:12:10 +01:00
parent f96e916184
commit 0f43274e33
8 changed files with 126 additions and 24 deletions

View file

@ -2,11 +2,16 @@ from django.conf import settings
from ninja import Router as APIRouter from ninja import Router as APIRouter
from ninja import Schema from ninja import Schema
from c3nav.api.utils import NonEmptyStr
auth_api_router = APIRouter(tags=["auth"]) auth_api_router = APIRouter(tags=["auth"])
class APITokenSchema(Schema): 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, @auth_api_router.get('/session/', response=APITokenSchema, auth=None,

View file

@ -14,7 +14,20 @@ from c3nav.control.models import UserPermissions
FakeRequest = namedtuple('FakeRequest', ('session', )) 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): def __init__(self, logged_in=False, superuser=False):
super().__init__() super().__init__()
self.logged_in = superuser or logged_in self.logged_in = superuser or logged_in

View file

@ -3,9 +3,16 @@ from typing import Literal
from ninja import Schema from ninja import Schema
from pydantic import Field as APIField from pydantic import Field as APIField
from c3nav.api.utils import NonEmptyStr
class APIErrorSchema(Schema): class APIErrorSchema(Schema):
detail: str """
An error has occured with this request
"""
detail: NonEmptyStr = APIField(
description="A human-readable error description"
)
class PolygonSchema(Schema): class PolygonSchema(Schema):

View file

@ -5,7 +5,7 @@ from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.utils.functional import cached_property from django.utils.functional import cached_property
from ninja import NinjaAPI from ninja import NinjaAPI, Swagger
from ninja.schema import NinjaGenerateJsonSchema from ninja.schema import NinjaGenerateJsonSchema
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.response import Response 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.api import SessionViewSet
from c3nav.api.exceptions import CustomAPIException from c3nav.api.exceptions import CustomAPIException
from c3nav.api.newapi import auth_api_router 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.editor.api import ChangeSetViewSet, EditorViewSet
from c3nav.mapdata.api import (AccessRestrictionGroupViewSet, AccessRestrictionViewSet, AreaViewSet, BuildingViewSet, from c3nav.mapdata.api import (AccessRestrictionGroupViewSet, AccessRestrictionViewSet, AreaViewSet, BuildingViewSet,
ColumnViewSet, CrossDescriptionViewSet, DoorViewSet, DynamicLocationPositionViewSet, ColumnViewSet, CrossDescriptionViewSet, DoorViewSet, DynamicLocationPositionViewSet,
@ -28,11 +28,42 @@ from c3nav.mesh.api import FirmwareViewSet
from c3nav.mesh.newapi import mesh_api_router from c3nav.mesh.newapi import mesh_api_router
from c3nav.routing.api import RoutingViewSet 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( ninja_api = NinjaAPI(
title="c3nav API", title="c3nav API",
version="v2", version="v2",
description=description,
docs_url="/", 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) 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 orig_normalize_name = NinjaGenerateJsonSchema.normalize_name
def wrap_normalize_name(self, name: str): # noqa def wrap_normalize_name(self, name: str): # noqa
return orig_normalize_name(self, name).removesuffix('Schema') return orig_normalize_name(self, name).removesuffix('Schema')

View file

@ -77,7 +77,11 @@ class LocationSchema(AccessRestrictionSchema, TitledSchema, LocationSlugSchema,
# todo: add_search # 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( min_zoom: float = APIField(
title="min zoom", title="min zoom",
) )

View file

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from ninja import Schema from ninja import Schema, Swagger
from pydantic import Field as APIField from pydantic import Field as APIField
from pydantic import PositiveFloat, PositiveInt from pydantic import PositiveFloat, PositiveInt
@ -9,6 +9,11 @@ from c3nav.mapdata.schemas.model_base import SpecificLocationSchema
class LevelSchema(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( short_label: NonEmptyStr = APIField(
title="short label (for level selector)", title="short label (for level selector)",
description="unique among levels", description="unique among levels",

View file

@ -3,4 +3,10 @@ from pydantic import Field as APIField
class BoundsSchema(Schema): 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)),
)

View file

@ -6,10 +6,11 @@ from ninja import Field as APIField
from ninja import Router as APIRouter from ninja import Router as APIRouter
from ninja import Schema, UploadedFile from ninja import Schema, UploadedFile
from ninja.pagination import paginate from ninja.pagination import paginate
from ninja.renderers import BaseRenderer
from pydantic import PositiveInt, field_validator from pydantic import PositiveInt, field_validator
from c3nav.api.exceptions import API404, APIConflict, APIRequestValidationFailed 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.dataformats import BoardType, ChipType, FirmwareImage
from c3nav.mesh.models import FirmwareBuild, FirmwareVersion from c3nav.mesh.models import FirmwareBuild, FirmwareVersion
@ -17,25 +18,47 @@ mesh_api_router = APIRouter(tags=["mesh"])
class FirmwareBuildSchema(Schema): class FirmwareBuildSchema(Schema):
"""
A build belonging to a firmware version.
"""
id: PositiveInt id: PositiveInt
variant: str = APIField(..., example="c3uart") variant: str = APIField(
chip: ChipType = APIField(..., example=ChipType.ESP32_C3.name) description="a variant identifier for this build, unique for this firmware version",
sha256_hash: str = APIField(..., pattern=r"^[0-9a-f]{64}$") example="c3uart"
url: str = APIField(..., alias="binary", example="/media/firmware/012345/firmware.bin") # todo: downlaod differently? )
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 # 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): class Config(Schema.Config):
pass pass
class FirmwareSchema(Schema): class FirmwareSchema(Schema):
"""
A firmware version, usually with multiple build variants.
"""
id: PositiveInt id: PositiveInt
project_name: str = APIField(..., example="c3nav_positioning") project_name: str = APIField(..., example="c3nav_positioning")
version: str = APIField(..., example="499837d-dirty") version: str = APIField(..., example="499837d-dirty")
idf_version: str = APIField(..., example="v5.1-476-g3187b8b326") idf_version: str = APIField(..., example="v5.1-476-g3187b8b326")
created: datetime created: datetime
builds: list[FirmwareBuildSchema] builds: list[FirmwareBuildSchema] = APIField(min_items=1)
@field_validator('builds') @field_validator('builds')
def builds_variants_must_be_unique(cls, builds): def builds_variants_must_be_unique(cls, builds):
@ -44,10 +67,6 @@ class FirmwareSchema(Schema):
return builds return builds
class Error(Schema):
detail: str
@mesh_api_router.get('/firmwares/', summary="List available firmwares", @mesh_api_router.get('/firmwares/', summary="List available firmwares",
response={200: list[FirmwareSchema], **validate_responses, **auth_responses}) response={200: list[FirmwareSchema], **validate_responses, **auth_responses})
@paginate @paginate
@ -66,7 +85,9 @@ def firmware_detail(request, firmware_id: int):
@mesh_api_router.get('/firmwares/{firmware_id}/{variant}/image_data', @mesh_api_router.get('/firmwares/{firmware_id}/{variant}/image_data',
summary="Get header data of firmware build image", 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): def firmware_build_image(request, firmware_id: int, variant: str):
try: try:
build = FirmwareBuild.objects.get(version_id=firmware_id, variant=variant) 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', @mesh_api_router.get('/firmwares/{firmware_id}/{variant}/project_description',
summary="Get project description of firmware build", 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): def firmware_project_description(request, firmware_id: int, variant: str):
try: try:
return FirmwareBuild.objects.get(version_id=firmware_id, variant=variant).firmware_description 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): class UploadFirmwareBuildSchema(Schema):
"""
A firmware build to upload, with at least one build variant
"""
variant: str = APIField(..., example="c3uart") variant: str = APIField(..., example="c3uart")
boards: list[BoardType] = APIField(..., example=[BoardType.C3NAV_LOCATION_PCB_REV_0_2.name, ]) boards: list[BoardType] = APIField(..., example=[BoardType.C3NAV_LOCATION_PCB_REV_0_2.name, ])
project_description: dict = APIField(..., title='project_description.json contents') project_description: dict = APIField(..., title='project_description.json contents')
@ -93,10 +119,13 @@ class UploadFirmwareBuildSchema(Schema):
class UploadFirmwareSchema(Schema): class UploadFirmwareSchema(Schema):
"""
A firmware version to upload, with at least one build variant
"""
project_name: str = APIField(..., example="c3nav_positioning") project_name: str = APIField(..., example="c3nav_positioning")
version: str = APIField(..., example="499837d-dirty") version: str = APIField(..., example="499837d-dirty")
idf_version: str = APIField(..., example="v5.1-476-g3187b8b326") 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') @field_validator('builds')
def builds_variants_must_be_unique(cls, builds): def builds_variants_must_be_unique(cls, builds):