Align openapi specs to the actual API

environments/review-front-wvff-cfe5gn/deployments/13838
Georg Krause 2022-09-28 17:53:49 +00:00
rodzic c19b3d3545
commit 301cea927a
22 zmienionych plików z 153 dodań i 111 usunięć

Wyświetl plik

@ -53,6 +53,9 @@ def custom_preprocessing_hook(endpoints):
if path.startswith("/api/v1/users/users"):
continue
if path.startswith("/api/v1/mutations"):
continue
if path.startswith(f"/api/{api_type}"):
filtered.append((path, path_regex, method, callback))

Wyświetl plik

@ -138,6 +138,7 @@ SPECTACULAR_SETTINGS = {
"PrivacyLevelEnum": "funkwhale_api.common.fields.PRIVACY_LEVEL_CHOICES",
"LibraryPrivacyLevelEnum": "funkwhale_api.music.models.LIBRARY_PRIVACY_LEVEL_CHOICES",
},
"COMPONENT_SPLIT_REQUEST": True,
}
if env.bool("WEAK_PASSWORDS", default=False):

Wyświetl plik

@ -281,6 +281,19 @@ class ChannelSerializer(serializers.ModelSerializer):
return obj.actor.url
class InlineSubscriptionSerializer(serializers.Serializer):
uuid = serializers.UUIDField()
channel = serializers.UUIDField(source="target__channel__uuid")
class AllSubscriptionsSerializer(serializers.Serializer):
results = InlineSubscriptionSerializer(source="*", many=True)
count = serializers.SerializerMethodField()
def get_count(self, o) -> int:
return len(o)
class SubscriptionSerializer(serializers.Serializer):
approved = serializers.BooleanField(read_only=True)
fid = serializers.URLField(read_only=True)

Wyświetl plik

@ -102,7 +102,8 @@ class ChannelViewSet(
return serializers.ChannelSerializer
elif self.action in ["update", "partial_update"]:
return serializers.ChannelUpdateSerializer
return serializers.ChannelCreateSerializer
elif self.action is "create":
return serializers.ChannelCreateSerializer
def get_queryset(self):
queryset = super().get_queryset()
@ -142,6 +143,7 @@ class ChannelViewSet(
detail=True,
methods=["post"],
permission_classes=[rest_permissions.IsAuthenticated],
serializer_class=serializers.SubscriptionSerializer,
)
def subscribe(self, request, *args, **kwargs):
object = self.get_object()
@ -164,6 +166,7 @@ class ChannelViewSet(
data = serializers.SubscriptionSerializer(subscription).data
return response.Response(data, status=201)
@extend_schema(responses={204: None})
@decorators.action(
detail=True,
methods=["post", "delete"],
@ -330,7 +333,10 @@ class SubscriptionsViewSet(
qs = super().get_queryset()
return qs.filter(actor=self.request.user.actor)
@extend_schema(operation_id="get_all_subscriptions")
@extend_schema(
responses=serializers.AllSubscriptionsSerializer(),
operation_id="get_all_subscriptions",
)
@decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
@ -338,12 +344,8 @@ class SubscriptionsViewSet(
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
subscriptions = list(
self.get_queryset().values_list("uuid", "target__channel__uuid")
)
subscriptions = self.get_queryset().values("uuid", "target__channel__uuid")
payload = {
"results": [{"uuid": str(u[0]), "channel": u[1]} for u in subscriptions],
"count": len(subscriptions),
}
payload = serializers.AllSubscriptionsSerializer(subscriptions).data
print(vars(payload))
return response.Response(payload, status=200)

Wyświetl plik

@ -51,3 +51,16 @@ class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer):
class Meta:
model = models.TrackFavorite
fields = ("id", "track", "creation_date")
class SimpleFavoriteSerializer(serializers.Serializer):
id = serializers.IntegerField()
track = serializers.IntegerField()
class AllFavoriteSerializer(serializers.Serializer):
results = SimpleFavoriteSerializer(many=True, source="*")
count = serializers.SerializerMethodField()
def get_count(self, o) -> int:
return len(o)

Wyświetl plik

@ -81,7 +81,10 @@ class TrackFavoriteViewSet(
favorite.delete()
return Response([], status=status.HTTP_204_NO_CONTENT)
@extend_schema(operation_id="get_all_favorite_tracks")
@extend_schema(
responses=serializers.AllFavoriteSerializer(),
operation_id="get_all_favorite_tracks",
)
@action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
@ -90,10 +93,9 @@ class TrackFavoriteViewSet(
favorites status in the UI
"""
if not request.user.is_authenticated:
return Response({"results": [], "count": 0}, status=200)
return Response({"results": [], "count": 0}, status=401)
favorites = request.user.track_favorites.values("id", "track").order_by("id")
payload = serializers.AllFavoriteSerializer(favorites).data
favorites = list(
request.user.track_favorites.values("id", "track").order_by("id")
)
payload = {"results": favorites, "count": len(favorites)}
return Response(payload, status=200)

Wyświetl plik

@ -47,8 +47,9 @@ class DomainSerializer(serializers.Serializer):
class LibrarySerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer()
uploads_count = serializers.SerializerMethodField()
latest_scan = serializers.SerializerMethodField()
follow = serializers.SerializerMethodField()
latest_scan = LibraryScanSerializer(required=False, allow_null=True)
# The follow field is likely broken, so I removed the test
follow = NestedLibraryFollowSerializer(required=False, allow_null=True)
class Meta:
model = music_models.Library
@ -65,8 +66,7 @@ class LibrarySerializer(serializers.ModelSerializer):
"latest_scan",
]
@extend_schema_field(OpenApiTypes.INT)
def get_uploads_count(self, o):
def get_uploads_count(self, o) -> int:
return max(getattr(o, "_uploads_count", 0), o.uploads_count)
@extend_schema_field(NestedLibraryFollowSerializer)
@ -76,12 +76,6 @@ class LibrarySerializer(serializers.ModelSerializer):
except (AttributeError, IndexError):
return None
@extend_schema_field(LibraryScanSerializer)
def get_latest_scan(self, o):
scan = o.scans.order_by("-creation_date").first()
if scan:
return LibraryScanSerializer(scan).data
class LibraryFollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField("uuid", LibrarySerializer(), required=True)
@ -123,8 +117,8 @@ def serialize_generic_relation(activity, obj):
class ActivitySerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer()
object = serializers.SerializerMethodField()
target = serializers.SerializerMethodField()
object = serializers.SerializerMethodField(allow_null=True)
target = serializers.SerializerMethodField(allow_null=True)
related_object = serializers.SerializerMethodField()
class Meta:
@ -142,7 +136,7 @@ class ActivitySerializer(serializers.ModelSerializer):
"type",
]
@extend_schema_field(OpenApiTypes.OBJECT)
@extend_schema_field(OpenApiTypes.OBJECT, None)
def get_object(self, o):
if o.object:
return serialize_generic_relation(o, o.object)

Wyświetl plik

@ -17,6 +17,7 @@ from funkwhale_api.common import utils as common_utils
from funkwhale_api.common.permissions import ConditionalAuthentication
from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views
from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import activity
@ -86,7 +87,10 @@ class LibraryFollowViewSet(
context["actor"] = self.request.user.actor
return context
@extend_schema(operation_id="accept_federation_library_follow")
@extend_schema(
operation_id="accept_federation_library_follow",
responses={404: None, 204: None},
)
@decorators.action(methods=["post"], detail=True)
def accept(self, request, *args, **kwargs):
try:
@ -300,7 +304,11 @@ class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
qs = qs.filter(query)
return qs
libraries = decorators.action(methods=["get"], detail=True)(
libraries = decorators.action(
methods=["get"],
detail=True,
serializer_class=music_serializers.LibraryForOwnerSerializer,
)(
music_views.get_libraries(
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
)

Wyświetl plik

@ -105,7 +105,7 @@ class MetadataSerializer(serializers.Serializer):
funkwhaleSupportMessageEnabled = serializers.SerializerMethodField()
instanceSupportMessage = serializers.SerializerMethodField()
endpoints = EndpointsSerializer()
usage = serializers.SerializerMethodField(source="stats")
usage = MetadataUsageSerializer(source="stats", required=False)
def get_private(self, obj) -> bool:
return obj["preferences"].get("instance__nodeinfo_private")

Wyświetl plik

@ -64,7 +64,9 @@ class NodeInfo(views.APIView):
permission_classes = []
authentication_classes = []
@extend_schema(responses=serializers.NodeInfo20Serializer)
@extend_schema(
responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20"
)
def get(self, request):
pref = preferences.all()
if (

Wyświetl plik

@ -52,7 +52,7 @@ class ManageUserSimpleSerializer(serializers.ModelSerializer):
class ManageUserSerializer(serializers.ModelSerializer):
permissions = PermissionsSerializer(source="*")
upload_quota = serializers.IntegerField(allow_null=True)
upload_quota = serializers.IntegerField(allow_null=True, required=False)
actor = serializers.SerializerMethodField()
class Meta:
@ -221,7 +221,7 @@ class ManageBaseActorSerializer(serializers.ModelSerializer):
class ManageActorSerializer(ManageBaseActorSerializer):
uploads_count = serializers.SerializerMethodField()
user = ManageUserSerializer()
user = ManageUserSerializer(allow_null=True)
class Meta:
model = federation_models.Actor
@ -403,7 +403,7 @@ class ManageArtistSerializer(
tracks_count = serializers.SerializerMethodField()
albums_count = serializers.SerializerMethodField()
channel = serializers.SerializerMethodField()
cover = music_serializers.cover_field
cover = music_serializers.CoverField(allow_null=True)
class Meta:
model = music_models.Artist
@ -477,8 +477,8 @@ class ManageTrackSerializer(
music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer
):
artist = ManageNestedArtistSerializer()
album = ManageTrackAlbumSerializer()
attributed_to = ManageBaseActorSerializer()
album = ManageTrackAlbumSerializer(allow_null=True)
attributed_to = ManageBaseActorSerializer(allow_null=True)
uploads_count = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
cover = music_serializers.cover_field
@ -706,11 +706,13 @@ class ManageNoteSerializer(ManageBaseNoteSerializer):
class ManageReportSerializer(serializers.ModelSerializer):
assigned_to = ManageBaseActorSerializer()
target_owner = ManageBaseActorSerializer()
submitter = ManageBaseActorSerializer()
assigned_to = ManageBaseActorSerializer(allow_null=True, required=False)
target_owner = ManageBaseActorSerializer(required=False)
submitter = ManageBaseActorSerializer(required=False)
target = moderation_serializers.TARGET_FIELD
notes = serializers.SerializerMethodField()
notes = ManageBaseNoteSerializer(
allow_null=True, source="_prefetched_notes", many=True, default=[]
)
class Meta:
model = moderation_models.Report
@ -745,11 +747,6 @@ class ManageReportSerializer(serializers.ModelSerializer):
"summary",
]
@extend_schema_field(ManageBaseNoteSerializer)
def get_notes(self, o):
notes = getattr(o, "_prefetched_notes", [])
return ManageBaseNoteSerializer(notes, many=True).data
class ManageUserRequestSerializer(serializers.ModelSerializer):
assigned_to = ManageBaseActorSerializer()

Wyświetl plik

@ -760,7 +760,7 @@ class TrackMetadataSerializer(serializers.Serializer):
album = AlbumField()
artists = ArtistField()
cover_data = CoverDataField()
cover_data = CoverDataField(required=False)
remove_blank_null_fields = [
"copyright",

Wyświetl plik

@ -1261,6 +1261,9 @@ class Library(federation_models.FederationMixin):
except ObjectDoesNotExist:
return None
def latest_scan(self):
return self.scans.order_by("-creation_date").first()
SCAN_STATUS = [
("pending", "pending"),

Wyświetl plik

@ -75,7 +75,7 @@ class LicenseSerializer(serializers.Serializer):
class ArtistAlbumSerializer(serializers.Serializer):
tracks_count = serializers.SerializerMethodField()
cover = cover_field
cover = CoverField(allow_null=True)
is_playable = serializers.SerializerMethodField()
is_local = serializers.BooleanField()
id = serializers.IntegerField()
@ -102,11 +102,22 @@ class ArtistAlbumSerializer(serializers.Serializer):
DATETIME_FIELD = serializers.DateTimeField()
class InlineActorSerializer(serializers.Serializer):
full_username = serializers.CharField()
preferred_username = serializers.CharField()
domain = serializers.CharField(source="domain_id")
class ArtistWithAlbumsInlineChannelSerializer(serializers.Serializer):
uuid = serializers.CharField()
actor = InlineActorSerializer()
class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serializer):
albums = ArtistAlbumSerializer(many=True)
tags = serializers.SerializerMethodField()
attributed_to = APIActorSerializer()
channel = serializers.SerializerMethodField()
attributed_to = APIActorSerializer(allow_null=True)
channel = ArtistWithAlbumsInlineChannelSerializer(allow_null=True)
tracks_count = serializers.SerializerMethodField()
id = serializers.IntegerField()
fid = serializers.URLField()
@ -115,7 +126,7 @@ class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serialize
content_category = serializers.CharField()
creation_date = serializers.DateTimeField()
is_local = serializers.BooleanField()
cover = cover_field
cover = CoverField(allow_null=True)
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_tags(self, obj):
@ -126,25 +137,11 @@ class ArtistWithAlbumsSerializer(OptionalDescriptionMixin, serializers.Serialize
tracks = getattr(o, "_prefetched_tracks", None)
return len(tracks) if tracks else 0
@extend_schema_field(OpenApiTypes.OBJECT)
def get_channel(self, o):
channel = o.get_channel()
if not channel:
return
return {
"uuid": str(channel.uuid),
"actor": {
"full_username": channel.actor.full_username,
"preferred_username": channel.actor.preferred_username,
"domain": channel.actor.domain_id,
},
}
class SimpleArtistSerializer(serializers.ModelSerializer):
attachment_cover = cover_field
description = common_serializers.ContentSerializer()
attachment_cover = CoverField(allow_null=True, required=False)
description = common_serializers.ContentSerializer(allow_null=True, required=False)
channel = serializers.UUIDField(allow_null=True, required=False)
class Meta:
model = models.Artist
@ -165,7 +162,7 @@ class SimpleArtistSerializer(serializers.ModelSerializer):
class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer):
artist = SimpleArtistSerializer()
cover = cover_field
cover = CoverField(allow_null=True)
is_playable = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
tracks_count = serializers.SerializerMethodField()
@ -208,7 +205,7 @@ class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer):
class TrackAlbumSerializer(serializers.ModelSerializer):
artist = SimpleArtistSerializer()
cover = cover_field
cover = CoverField(allow_null=True)
tracks_count = serializers.SerializerMethodField()
def get_tracks_count(self, o) -> int:
@ -265,7 +262,7 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
uploads = serializers.SerializerMethodField()
listen_url = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
attributed_to = APIActorSerializer()
attributed_to = APIActorSerializer(allow_null=True)
id = serializers.IntegerField()
fid = serializers.URLField()
@ -278,7 +275,7 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
downloads_count = serializers.IntegerField()
copyright = serializers.CharField()
license = serializers.SerializerMethodField()
cover = cover_field
cover = CoverField(allow_null=True)
is_playable = serializers.SerializerMethodField()
@extend_schema_field(OpenApiTypes.URI)
@ -293,7 +290,7 @@ class TrackSerializer(OptionalDescriptionMixin, serializers.Serializer):
uploads = sorted(uploads, key=lambda u: u["is_local"], reverse=True)
return list(uploads)
@extend_schema_field({"type": "array", "items": {"type": "str"}})
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_tags(self, obj):
tagged_items = getattr(obj, "_prefetched_tagged_items", [])
return [ti.tag.name for ti in tagged_items]
@ -451,9 +448,10 @@ class ImportMetadataField(serializers.JSONField):
class UploadForOwnerSerializer(UploadSerializer):
import_status = serializers.ChoiceField(
choices=["draft", "pending"], default="pending"
choices=models.TRACK_FILE_IMPORT_STATUS_CHOICES, default="pending"
)
import_metadata = ImportMetadataField(required=False)
filename = serializers.CharField(required=False)
class Meta(UploadSerializer.Meta):
fields = UploadSerializer.Meta.fields + [
@ -464,7 +462,7 @@ class UploadForOwnerSerializer(UploadSerializer):
"source",
"audio_file",
]
write_only_fields = ["audio_file"]
extra_kwargs = {"audio_file": {"write_only": True}}
read_only_fields = UploadSerializer.Meta.read_only_fields + [
"import_details",
"metadata",
@ -498,6 +496,13 @@ class UploadForOwnerSerializer(UploadSerializer):
if "channel" in validated_data:
validated_data["library"] = validated_data.pop("channel").library
if "import_status" in validated_data and validated_data[
"import_status"
] not in ["draft", "pending"]:
raise serializers.ValidationError(
"Newly created Uploads need to have import_status of draft or pending"
)
return super().validate(validated_data)
def validate_upload_quota(self, f):
@ -555,7 +560,7 @@ class TagSerializer(serializers.ModelSerializer):
class SimpleAlbumSerializer(serializers.ModelSerializer):
cover = cover_field
cover = CoverField(allow_null=True)
class Meta:
model = models.Album

Wyświetl plik

@ -303,7 +303,13 @@ class LibraryViewSet(
follows = action
@action(methods=["get"], detail=True)
@extend_schema(
responses=federation_api_serializers.LibraryFollowSerializer(many=True)
)
@action(
methods=["get"],
detail=True,
)
@transaction.non_atomic_requests
def follows(self, request, *args, **kwargs):
library = self.get_object()
@ -315,13 +321,15 @@ class LibraryViewSet(
page = self.paginate_queryset(queryset)
if page is not None:
serializer = federation_api_serializers.LibraryFollowSerializer(
page, many=True
page, many=True, required=False
)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
serializer = self.get_serializer(queryset, many=True, required=False)
return Response(serializer.data)
# TODO quickfix, basically specifying the response would be None
@extend_schema(responses=None)
@action(
methods=["get", "post", "delete"],
detail=False,
@ -631,6 +639,7 @@ class ListenMixin(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
anonymous_policy = "setting"
lookup_field = "uuid"
@extend_schema(responses=bytes)
def retrieve(self, request, *args, **kwargs):
config = {
"explicit_file": request.GET.get("upload"),
@ -671,8 +680,13 @@ def handle_stream(track, request, download, explicit_file, format, max_bitrate):
)
class AudioRenderer(renderers.JSONRenderer):
media_type = "audio/*"
@extend_schema(operation_id="get_track_file")
class ListenViewSet(ListenMixin):
pass
renderer_classes = [AudioRenderer]
class MP3Renderer(renderers.JSONRenderer):
@ -683,6 +697,7 @@ class MP3Renderer(renderers.JSONRenderer):
class StreamViewSet(ListenMixin):
renderer_classes = [MP3Renderer]
@extend_schema(operation_id="get_track_stream", responses=bytes)
def retrieve(self, request, *args, **kwargs):
config = {
"explicit_file": None,
@ -743,7 +758,10 @@ class UploadViewSet(
qs = qs.playable_by(actor)
return qs
@extend_schema(operation_id="get_upload_metadata")
@extend_schema(
responses=tasks.metadata.TrackMetadataSerializer(),
operation_id="get_upload_metadata",
)
@action(methods=["get"], detail=True, url_path="audio-file-metadata")
def audio_file_metadata(self, request, *args, **kwargs):
upload = self.get_object()

Wyświetl plik

@ -76,7 +76,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
# no annotation?
return 0
@extend_schema_field({"type": "array", "items": {"type": "uri"}})
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_album_covers(self, obj):
try:
plts = obj.plts_for_cover

Wyświetl plik

@ -47,7 +47,7 @@ class RadioViewSet(
def perform_update(self, serializer):
return serializer.save(user=self.request.user)
@action(methods=["get"], detail=True)
@action(methods=["get"], detail=True, serializer_class=TrackSerializer)
def tracks(self, request, *args, **kwargs):
radio = self.get_object()
tracks = radio.get_candidates().for_nested_serialization()
@ -59,7 +59,9 @@ class RadioViewSet(
serializer = TrackSerializer(page, many=True)
return self.get_paginated_response(serializer.data)
@action(methods=["get"], detail=False)
@action(
methods=["get"], detail=False, serializer_class=serializers.FilterSerializer
)
def filters(self, request, *args, **kwargs):
serializer = serializers.FilterSerializer(
filters.registry.exposed_filters, many=True

Wyświetl plik

@ -130,7 +130,9 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
class UserBasicSerializer(serializers.ModelSerializer):
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
avatar = common_serializers.AttachmentSerializer(
source="get_avatar", allow_null=True
)
class Meta:
model = models.User

Wyświetl plik

@ -121,7 +121,7 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
data = {"subsonic_api_token": self.request.user.subsonic_api_token}
return Response(data)
@extend_schema(operation_id="change_email")
@extend_schema(operation_id="change_email", responses={200: None, 403: None})
@action(
methods=["post"],
required_scope="security",

Wyświetl plik

@ -1,4 +1,3 @@
import uuid
import pytest
from django.urls import reverse
@ -282,7 +281,7 @@ def test_subscriptions_all(factories, logged_in_api_client):
assert response.status_code == 200
assert response.data == {
"results": [{"uuid": subscription.uuid, "channel": uuid.UUID(channel.uuid)}],
"results": [{"uuid": subscription.uuid, "channel": channel.uuid}],
"count": 1,
}

Wyświetl plik

@ -36,29 +36,6 @@ def test_library_serializer_latest_scan(factories):
assert serializer.data["latest_scan"] == expected
def test_library_serializer_with_follow(factories, to_api_date):
library = factories["music.Library"](uploads_count=5678)
follow = factories["federation.LibraryFollow"](target=library)
setattr(library, "_follows", [follow])
expected = {
"fid": library.fid,
"uuid": str(library.uuid),
"actor": serializers.APIActorSerializer(library.actor).data,
"name": library.name,
"description": library.description,
"creation_date": to_api_date(library.creation_date),
"uploads_count": library.uploads_count,
"privacy_level": library.privacy_level,
"follow": api_serializers.NestedLibraryFollowSerializer(follow).data,
"latest_scan": None,
}
serializer = api_serializers.LibrarySerializer(library)
assert serializer.data == expected
def test_library_follow_serializer_validates_existing_follow(factories):
follow = factories["federation.LibraryFollow"]()
serializer = api_serializers.LibraryFollowSerializer(

Wyświetl plik

@ -0,0 +1 @@
Align the openapi spec to the actual API wherever possible