Generate API docs (#6319)

* Add basic task for generating apidocs

* Fix SPECTACTULAR_SETTINGS

- Some provided options were not correct

* Update .gitignore

* Fix for duplicated API path

- `/api/plugins/activate` routed to PluginActivate view
- Must be associated with a specific plugin ID

* By default, fail if warnings are raised

* Use GenericAPIView for GetAuthToken

* Use GenericAPIView for RolesDetail endpoint

* Refactor more endpoints to use GenericApiView

* More API cleanup

* Add extra type hints for exposed methods

* Update RoleDetails endpoint

- Specify serializer
- Use RetrieveAPI class type

* More type hints

* Export API docs as part of CI

* add more api views docs

* even more docs

* extend tests to api-version

* simplify serializer

* and more docs

* fix serializer

* added more API docs

* clean diff

* Added APISearch base

* do not assume you know the user
he might be anonymously creating the schema ;-)

* set empty serializer where no input is needed

* Use dummy model for schema generation

* fix OpenAPI docs section

* Update .github/workflows/qc_checks.yaml

Co-authored-by: Matthias Mair <code@mjmair.com>

* REmove duplicate commands

* Ignore warnings in CI

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver 2024-02-08 16:19:57 +11:00 committed by GitHub
parent a99ba75fed
commit af4d888b1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 271 additions and 36 deletions

View File

@ -112,6 +112,33 @@ jobs:
pip install linkcheckmd requests
python -m linkcheckmd docs --recurse
schema:
name: Tests - API Schema Documentation
runs-on: ubuntu-20.04
needs: paths-filter
if: needs.paths-filter.outputs.server == 'true'
env:
INVENTREE_DB_ENGINE: django.db.backends.sqlite3
INVENTREE_DB_NAME: ../inventree_unit_test_db.sqlite3
INVENTREE_ADMIN_USER: testuser
INVENTREE_ADMIN_PASSWORD: testpassword
INVENTREE_ADMIN_EMAIL: test@test.com
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
INVENTREE_PYTHON_TEST_USERNAME: testuser
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils
dev-install: true
update: true
- name: Export API Documentation
run: |
invoke schema --ignore-warnings
python:
name: Tests - inventree-python
runs-on: ubuntu-20.04

5
.gitignore vendored
View File

@ -48,6 +48,7 @@ label.pdf
label.png
InvenTree/my_special*
_tests*.txt
schema.yml
# Local static and media file storage (only when running in development mode)
inventree_media
@ -70,6 +71,7 @@ secret_key.txt
.idea/
*.code-workspace
.bash_history
.DS_Store
# https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
.vscode/*
@ -107,5 +109,8 @@ InvenTree/plugins/
*.mo
messages.ts
# Generated API schema file
api.yaml
# web frontend (static files)
InvenTree/web/static

View File

@ -1,5 +1,7 @@
"""Main JSON interface views."""
import sys
from django.conf import settings
from django.db import transaction
from django.http import JsonResponse
@ -8,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
@ -18,6 +21,7 @@ from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI
from InvenTree.permissions import RolePermission
from InvenTree.templatetags.inventree_extras import plugins_info
from part.models import Part
from plugin.serializers import MetadataSerializer
from users.models import ApiToken
@ -28,11 +32,41 @@ from .version import inventreeApiText
from .views import AjaxView
class VersionViewSerializer(serializers.Serializer):
"""Serializer for a single version."""
class VersionSerializer(serializers.Serializer):
"""Serializer for server version."""
server = serializers.CharField()
api = serializers.IntegerField()
commit_hash = serializers.CharField()
commit_date = serializers.CharField()
commit_branch = serializers.CharField()
python = serializers.CharField()
django = serializers.CharField()
class LinkSerializer(serializers.Serializer):
"""Serializer for all possible links."""
doc = serializers.URLField()
code = serializers.URLField()
credit = serializers.URLField()
app = serializers.URLField()
bug = serializers.URLField()
dev = serializers.BooleanField()
up_to_date = serializers.BooleanField()
version = VersionSerializer()
links = LinkSerializer()
class VersionView(APIView):
"""Simple JSON endpoint for InvenTree version information."""
permission_classes = [permissions.IsAdminUser]
@extend_schema(responses={200: OpenApiResponse(response=VersionViewSerializer)})
def get(self, request, *args, **kwargs):
"""Return information about the InvenTree server."""
return JsonResponse({
@ -81,6 +115,8 @@ class VersionApiSerializer(serializers.Serializer):
class VersionTextView(ListAPI):
"""Simple JSON endpoint for InvenTree version text."""
serializer_class = VersionSerializer
permission_classes = [permissions.IsAdminUser]
@extend_schema(responses={200: OpenApiResponse(response=VersionApiSerializer)})
@ -324,7 +360,17 @@ class AttachmentMixin:
attachment.save()
class APISearchView(APIView):
class APISearchViewSerializer(serializers.Serializer):
"""Serializer for the APISearchView."""
search = serializers.CharField()
search_regex = serializers.BooleanField(default=False, required=False)
search_whole = serializers.BooleanField(default=False, required=False)
limit = serializers.IntegerField(default=1, required=False)
offset = serializers.IntegerField(default=0, required=False)
class APISearchView(GenericAPIView):
"""A general-purpose 'search' API endpoint.
Returns hits against a number of different models simultaneously,
@ -334,6 +380,7 @@ class APISearchView(APIView):
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = APISearchViewSerializer
def get_result_types(self):
"""Construct a list of search types we can return."""
@ -446,4 +493,7 @@ class MetadataView(RetrieveUpdateAPI):
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance."""
# Detect if we are currently generating the OpenAPI schema
if 'spectacular' in sys.argv:
return MetadataSerializer(Part, *args, **kwargs)
return MetadataSerializer(self.get_model_type(), *args, **kwargs)

View File

@ -9,8 +9,8 @@ from django.utils.translation import gettext_lazy as _
import sesame.utils
from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
import InvenTree.version
@ -38,7 +38,7 @@ class GetSimpleLoginSerializer(serializers.Serializer):
email = serializers.CharField(label=_('Email'))
class GetSimpleLoginView(APIView):
class GetSimpleLoginView(GenericAPIView):
"""View to send a simple login link."""
permission_classes = ()

View File

@ -28,6 +28,10 @@ from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
from InvenTree.helpers_model import download_image_from_url, get_base_url
class EmptySerializer(serializers.Serializer):
"""Empty serializer for use in testing."""
class InvenTreeMoneySerializer(MoneyField):
"""Custom serializer for 'MoneyField', which ensures that passed values are numerically valid.

View File

@ -517,12 +517,15 @@ if USE_JWT:
SPECTACULAR_SETTINGS = {
'TITLE': 'InvenTree API',
'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system',
'LICENSE': {'MIT': 'https://github.com/inventree/InvenTree/blob/master/LICENSE'},
'EXTERNAL_DOCS': {
'docs': 'https://docs.inventree.org',
'web': 'https://inventree.org',
'LICENSE': {
'name': 'MIT',
'url': 'https://github.com/inventree/InvenTree/blob/master/LICENSE',
},
'VERSION': inventreeApiVersion(),
'EXTERNAL_DOCS': {
'description': 'More information about InvenTree in the official docs',
'url': 'https://docs.inventree.org',
},
'VERSION': str(inventreeApiVersion()),
'SERVE_INCLUDE_SCHEMA': False,
}

View File

@ -9,6 +9,7 @@ from allauth.account.models import EmailAddress
from allauth.socialaccount import providers
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2LoginView
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import serializers
from rest_framework.exceptions import NotFound
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
@ -16,7 +17,7 @@ from rest_framework.response import Response
import InvenTree.sso
from common.models import InvenTreeSetting
from InvenTree.mixins import CreateAPI, ListAPI, ListCreateAPI
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import EmptySerializer, InvenTreeModelSerializer
logger = logging.getLogger('inventree')
@ -112,11 +113,36 @@ for name, provider in providers.registry.provider_map.items():
social_auth_urlpatterns += provider_urlpatterns
class SocialProviderListResponseSerializer(serializers.Serializer):
"""Serializer for the SocialProviderListView."""
class SocialProvider(serializers.Serializer):
"""Serializer for the SocialProviderListResponseSerializer."""
id = serializers.CharField()
name = serializers.CharField()
configured = serializers.BooleanField()
login = serializers.URLField()
connect = serializers.URLField()
display_name = serializers.CharField()
sso_enabled = serializers.BooleanField()
sso_registration = serializers.BooleanField()
mfa_required = serializers.BooleanField()
providers = SocialProvider(many=True)
registration_enabled = serializers.BooleanField()
password_forgotten_enabled = serializers.BooleanField()
class SocialProviderListView(ListAPI):
"""List of available social providers."""
permission_classes = (AllowAny,)
serializer_class = EmptySerializer
@extend_schema(
responses={200: OpenApiResponse(response=SocialProviderListResponseSerializer)}
)
def get(self, request, *args, **kwargs):
"""Get the list of providers."""
provider_list = []

View File

@ -18,6 +18,11 @@ class ApiVersionTests(InvenTreeAPITestCase):
self.assertEqual(len(data), 10)
response = self.client.get(reverse('api-version'), format='json').json()
self.assertIn('version', response)
self.assertIn('dev', response)
self.assertIn('up_to_date', response)
def test_inventree_api_text(self):
"""Test that the inventreeApiText function works expected."""
# Normal run

View File

@ -11,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt
import django_q.models
from django_q.tasks import async_task
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
from rest_framework import permissions, serializers
from rest_framework.exceptions import NotAcceptable, NotFound
@ -53,7 +54,15 @@ class WebhookView(CsrfExemptMixin, APIView):
permission_classes = []
model_class = common.models.WebhookEndpoint
run_async = False
serializer_class = None
@extend_schema(
responses={
200: OpenApiResponse(
description='Any data can be posted to the endpoint - everything will be passed to the WebhookEndpoint model.'
)
}
)
def post(self, request, endpoint, *args, **kwargs):
"""Process incoming webhook."""
# get webhook definition
@ -115,6 +124,7 @@ class CurrencyExchangeView(APIView):
"""API endpoint for displaying currency information."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = None
def get(self, request, format=None):
"""Return information on available currency conversions."""
@ -157,6 +167,7 @@ class CurrencyRefreshView(APIView):
"""
permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser]
serializer_class = None
def post(self, request, *args, **kwargs):
"""Performing a POST request will update currency exchange rates."""
@ -516,6 +527,7 @@ class BackgroundTaskOverview(APIView):
"""Provides an overview of the background task queue status."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
serializer_class = None
def get(self, request, format=None):
"""Return information about the current status of the background task queue."""

View File

@ -2868,7 +2868,7 @@ class NotificationMessage(models.Model):
"""Return API endpoint."""
return reverse('api-notifications-list')
def age(self):
def age(self) -> int:
"""Age of the message in seconds."""
# Add timezone information if TZ is enabled (in production mode mostly)
delta = now() - (
@ -2878,7 +2878,7 @@ class NotificationMessage(models.Model):
)
return delta.seconds
def age_human(self):
def age_human(self) -> str:
"""Humanized age."""
return naturaltime(self.creation)

View File

@ -59,6 +59,8 @@ class SettingsSerializer(InvenTreeModelSerializer):
units = serializers.CharField(read_only=True)
typ = serializers.CharField(read_only=True)
def get_choices(self, obj):
"""Returns the choices available for a given item."""
results = []
@ -195,7 +197,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
user = serializers.PrimaryKeyRelatedField(read_only=True)
read = serializers.BooleanField()
def get_target(self, obj):
def get_target(self, obj) -> dict:
"""Function to resolve generic object reference to target."""
target = get_objectreference(obj, 'target_content_type', 'target_object_id')
@ -217,7 +219,7 @@ class NotificationMessageSerializer(InvenTreeModelSerializer):
return target
def get_source(self, obj):
def get_source(self, obj) -> dict:
"""Function to resolve generic object reference to source."""
return get_objectreference(obj, 'source_content_type', 'source_object_id')

View File

@ -2,15 +2,24 @@
import inspect
from rest_framework import permissions
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from InvenTree.serializers import EmptySerializer
from .states import StatusCode
class StatusView(APIView):
class StatusViewSerializer(serializers.Serializer):
"""Serializer for the StatusView responses."""
class_name = serializers.CharField()
values = serializers.DictField()
class StatusView(GenericAPIView):
"""Generic API endpoint for discovering information on 'status codes' for a particular model.
This class should be implemented as a subclass for each type of status.
@ -28,12 +37,19 @@ class StatusView(APIView):
status_model = self.kwargs.get(self.MODEL_REF, None)
if status_model is None:
raise ValidationError(
raise serializers.ValidationError(
f"StatusView view called without '{self.MODEL_REF}' parameter"
)
return status_model
@extend_schema(
description='Retrieve information about a specific status code',
responses={
200: OpenApiResponse(description='Status code information'),
400: OpenApiResponse(description='Invalid request'),
},
)
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""
status_class = self.get_status_model()
@ -53,6 +69,7 @@ class AllStatusViews(StatusView):
"""Endpoint for listing all defined status models."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = EmptySerializer
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""

View File

@ -49,6 +49,7 @@ from InvenTree.mixins import (
UpdateAPI,
)
from InvenTree.permissions import RolePermission
from InvenTree.serializers import EmptySerializer
from InvenTree.status_codes import (
BuildStatusGroups,
PurchaseOrderStatusGroups,
@ -487,6 +488,7 @@ class PartScheduling(RetrieveAPI):
"""
queryset = Part.objects.all()
serializer_class = EmptySerializer
def retrieve(self, request, *args, **kwargs):
"""Return scheduling information for the referenced Part instance."""
@ -687,6 +689,7 @@ class PartRequirements(RetrieveAPI):
"""
queryset = Part.objects.all()
serializer_class = EmptySerializer
def retrieve(self, request, *args, **kwargs):
"""Construct a response detailing Part requirements."""
@ -738,6 +741,7 @@ class PartSerialNumberDetail(RetrieveAPI):
"""API endpoint for returning extra serial number information about a particular part."""
queryset = Part.objects.all()
serializer_class = EmptySerializer
def retrieve(self, request, *args, **kwargs):
"""Return serial number information for the referenced Part instance."""
@ -1068,7 +1072,11 @@ class PartMixin:
# Pass a list of "starred" parts to the current user to the serializer
# We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None:
if (
self.starred_parts is None
and self.request is not None
and hasattr(self.request.user, 'starred_parts')
):
self.starred_parts = [
star.part for star in self.request.user.starred_parts.all()
]

View File

@ -748,7 +748,7 @@ class Part(
return stock[0].serial
@property
def full_name(self):
def full_name(self) -> str:
"""Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in InvenTree settings."""
return part_helpers.render_part_full_name(self)
@ -762,7 +762,7 @@ class Part(
return helpers.getMediaUrl(self.image.url)
return helpers.getBlankImage()
def get_thumbnail_url(self):
def get_thumbnail_url(self) -> str:
"""Return the URL of the image thumbnail for this part."""
if self.image:
return helpers.getMediaUrl(self.image.thumbnail.url)

View File

@ -91,7 +91,7 @@ class CategorySerializer(InvenTree.serializers.InvenTreeModelSerializer):
if not path_detail:
self.fields.pop('path')
def get_starred(self, category):
def get_starred(self, category) -> bool:
"""Return True if the category is directly "starred" by the current user."""
return category in self.context.get('starred_categories', [])
@ -723,7 +723,7 @@ class PartSerializer(
return queryset
def get_starred(self, part):
def get_starred(self, part) -> bool:
"""Return "true" if the part is starred by the current user."""
return part in self.starred_parts

View File

@ -2,17 +2,25 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions
from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from plugin import registry
class ActionPluginView(APIView):
class ActionPluginSerializer(serializers.Serializer):
"""Serializer for the ActionPluginView responses."""
action = serializers.CharField()
data = serializers.DictField()
class ActionPluginView(GenericAPIView):
"""Endpoint for running custom action plugins."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = ActionPluginSerializer
def post(self, request, *args, **kwargs):
"""This function checks if all required info was submitted and then performs a plugin_action or returns an error."""

View File

@ -1,22 +1,37 @@
"""API for location plugins."""
from rest_framework import permissions
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.exceptions import NotFound, ParseError
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from InvenTree.tasks import offload_task
from plugin.registry import registry
from stock.models import StockItem, StockLocation
class LocatePluginView(APIView):
class LocatePluginSerializer(serializers.Serializer):
"""Serializer for the LocatePluginView API endpoint."""
plugin = serializers.CharField(
help_text='Plugin to use for location identification'
)
item = serializers.IntegerField(required=False, help_text='StockItem to identify')
location = serializers.IntegerField(
required=False, help_text='StockLocation to identify'
)
class LocatePluginView(GenericAPIView):
"""Endpoint for using a custom plugin to identify or 'locate' a stock item or location."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = LocatePluginSerializer
def post(self, request, *args, **kwargs):
"""Check inputs and offload the task to the plugin."""
"""Identify or 'locate' a stock item or location with a plugin."""
# Check inputs and offload the task to the plugin
# Which plugin to we wish to use?
plugin = request.data.get('plugin', None)

View File

@ -247,6 +247,7 @@ class NotificationUserSettingSerializer(GenericReferencedSettingSerializer):
EXTRA_FIELDS = ['method']
method = serializers.CharField(read_only=True)
typ = serializers.CharField(read_only=True)
class PluginRegistryErrorSerializer(serializers.Serializer):

View File

@ -184,7 +184,7 @@ class StockLocation(
)
@property
def icon(self):
def icon(self) -> str:
"""Get the current icon used for this location.
The icon field on this model takes precedences over the possibly assigned stock location type

View File

@ -9,6 +9,7 @@ from django.urls import include, path, re_path
from django.views.generic.base import RedirectView
from dj_rest_auth.views import LogoutView
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
from rest_framework import exceptions, permissions
from rest_framework.response import Response
from rest_framework.views import APIView
@ -110,6 +111,7 @@ class RoleDetails(APIView):
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = None
def get(self, request, *args, **kwargs):
"""Return the list of roles / permissions available to the current user."""
@ -203,10 +205,17 @@ class GroupList(ListCreateAPI):
ordering_fields = ['name']
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged out')}
)
)
class Logout(LogoutView):
"""API view for logging out via API."""
def logout(self, request):
serializer_class = None
def post(self, request):
"""Logout the current user.
Deletes user token associated with request.
@ -230,6 +239,7 @@ class GetAuthToken(APIView):
"""Return authentication token for an authenticated user."""
permission_classes = [permissions.IsAuthenticated]
serializer_class = None
def get(self, request, *args, **kwargs):
"""Return an API token if the user is authenticated.

View File

@ -1,12 +1,12 @@
"""DRF API serializers for the 'users' app."""
from django.contrib.auth.models import Group
from django.contrib.auth.models import Group, User
from rest_framework import serializers
from InvenTree.serializers import InvenTreeModelSerializer
from .models import Owner
from .models import Owner, RuleSet, check_user_role
class OwnerSerializer(InvenTreeModelSerializer):
@ -31,3 +31,39 @@ class GroupSerializer(InvenTreeModelSerializer):
model = Group
fields = ['pk', 'name']
class RoleSerializer(InvenTreeModelSerializer):
"""Serializer for a roles associated with a given user."""
class Meta:
"""Metaclass options."""
model = User
fields = ['user', 'username', 'is_staff', 'is_superuser', 'roles']
user = serializers.IntegerField(source='pk')
username = serializers.CharField()
is_staff = serializers.BooleanField()
is_superuser = serializers.BooleanField()
roles = serializers.SerializerMethodField()
def get_roles(self, user: User) -> dict:
"""Return roles associated with the specified User."""
roles = {}
for ruleset in RuleSet.RULESET_CHOICES:
role, _text = ruleset
permissions = []
for permission in RuleSet.RULESET_PERMISSIONS:
if check_user_role(user, role, permission):
permissions.append(permission)
if len(permissions) > 0:
roles[role] = permissions
else:
roles[role] = None # pragma: no cover
return roles

View File

@ -894,10 +894,16 @@ def setup_test(c, ignore_update=False, dev=False, path='inventree-demo-dataset')
'overwrite': 'Overwrite existing files without asking first (default = off/False)',
}
)
def schema(c, filename='schema.yml', overwrite=False):
def schema(c, filename='schema.yml', overwrite=False, ignore_warnings=False):
"""Export current API schema."""
check_file_existance(filename, overwrite)
manage(c, f'spectacular --file {filename}')
cmd = f'spectacular --file {filename} --validate --color'
if not ignore_warnings:
cmd += ' --fail-on-warn'
manage(c, cmd, pty=True)
@task(default=True)