Coverage for api/docs/base_docs.py: 96%
80 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 06:14 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 06:14 +0000
1from http.client import responses as http_responses
2from textwrap import dedent
4from django.conf import settings
5from rest_framework.exceptions import (
6 APIException,
7 NotFound,
8 ValidationError,
9)
11from drf_spectacular.extensions import OpenApiSerializerExtension
12from drf_spectacular.openapi import AutoSchema
13from drf_spectacular.utils import (
14 OpenApiExample,
15 OpenApiParameter,
16 OpenApiResponse,
17 extend_schema,
18)
20from api.constants.media_types import MediaType
21from api.constants.parameters import COLLECTION, TAG
24def fields_to_md(field_names):
25 """
26 Create a Markdown representation of the given list of names to use in Swagger docs.
28 :param field_names: the list of field names to convert to Markdown
29 :return: the names as a Markdown string
30 """
32 *all_but_last, last = field_names
33 all_but_last = ", ".join([f"`{name}`" for name in all_but_last])
34 return f"{all_but_last} and `{last}`"
37class APIExceptionOpenApiSerializerExtension(OpenApiSerializerExtension):
38 target_class = APIException
39 match_subclasses = True
41 @classmethod
42 def _get_detail(cls, target):
43 return getattr(target, "detail", target.default_detail)
45 def get_name(self, *args):
46 cls = self.target if isinstance(self.target, type) else self.target.__class__
47 return cls.__name__
49 def map_serializer(self, *args):
50 cls = self.target if isinstance(self.target, type) else self.target.__class__
52 detail_string = {
53 "type": "string",
54 "description": "A description of what went wrong.",
55 }
57 if cls == ValidationError or issubclass(cls, ValidationError):
58 return {
59 "title": "ValidationError",
60 "type": "object",
61 "properties": {
62 "detail": {
63 "oneOf": [
64 detail_string,
65 {
66 "type": "object",
67 "additionalProperties": True,
68 },
69 ]
70 }
71 },
72 }
74 return {
75 "title": cls.__name__,
76 "type": "object",
77 "properties": {"detail": detail_string},
78 }
80 @classmethod
81 def exception_example(cls, exception):
82 if exception == ValidationError:
83 return {"detail": {"<request parameter>": "<error details>"}}
85 return {"detail": cls._get_detail(exception)}
88def get_examples(code, serializer, example):
89 if (
90 not example
91 and isinstance(serializer, type)
92 and issubclass(serializer, APIException)
93 ):
94 example = APIExceptionOpenApiSerializerExtension.exception_example(serializer)
95 elif example:
96 example = example
97 else:
98 return []
100 return [
101 OpenApiExample(
102 http_responses[code],
103 value=example,
104 )
105 ]
108def custom_extend_schema(**kwargs):
109 extend_args = {}
111 description = kwargs.pop("desc", None)
112 if description:
113 description = dedent(description)
114 extend_args["description"] = f"{description}"
116 parameters = kwargs.pop("params", [])
117 if not isinstance(parameters, list):
118 parameters = [parameters]
119 if parameters:
120 extend_args["parameters"] = parameters
122 responses = kwargs.pop("res", {})
123 if responses: 123 ↛ 134line 123 didn't jump to line 134 because the condition on line 123 was always true
124 responses = {
125 code: OpenApiResponse(
126 serializer,
127 description=http_responses[code],
128 examples=get_examples(code, serializer, example),
129 )
130 for code, (serializer, example) in responses.items()
131 }
132 extend_args["responses"] = responses
134 eg = kwargs.pop("eg", [])
135 if eg: 135 ↛ 141line 135 didn't jump to line 141 because the condition on line 135 was always true
136 # Docs: https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/
137 extend_args["extensions"] = {
138 "x-codeSamples": [{"lang": "cURL", "source": example} for example in eg]
139 }
141 return extend_schema(**extend_args, **kwargs)
144class MediaSchema(AutoSchema):
145 """
146 Overrides the default schema generator provided by drf-spectacular to adapt
147 to the conventions of the Openverse API documentation.
148 """
150 def get_description(self) -> str:
151 return f"""{super().get_description()}"""
153 def get_operation_id(self) -> str:
154 operation_tokens = super().get_operation_id().split("_")[0:-1]
155 if self.method == "GET" and len(operation_tokens) == 1:
156 if self._is_list_view():
157 operation_tokens.append("search")
158 else:
159 operation_tokens.append("detail")
160 return "_".join(operation_tokens)
163source_404_message = "Invalid source 'name'. Valid sources are ..."
164source_404_response = OpenApiResponse(
165 NotFound,
166 examples=[
167 OpenApiExample(
168 name="404",
169 value={"detail": source_404_message},
170 )
171 ],
172)
175def build_source_path_parameter(media_type: MediaType):
176 valid_description = (
177 f"Valid values are source_names from the stats endpoint: "
178 f"{settings.CANONICAL_ORIGIN}/v1/{media_type}/stats/."
179 )
181 return OpenApiParameter(
182 name="source",
183 type={
184 "type": "string",
185 "pattern": "^[^/.]+?$",
186 },
187 location=OpenApiParameter.PATH,
188 description=f"The source of {media_type}. {valid_description}",
189 )
192creator_path_parameter = OpenApiParameter(
193 name="creator",
194 type={
195 "type": "string",
196 "pattern": "^.+$",
197 },
198 location=OpenApiParameter.PATH,
199 description="The name of the media creator. This parameter "
200 "is case-sensitive, and matches exactly.",
201)
202tag_path_parameter = OpenApiParameter(
203 name="tag",
204 type={
205 "type": "string",
206 "pattern": "^[^/.]+?$",
207 },
208 location=OpenApiParameter.PATH,
209 description="The tag of the media. Not case-sensitive, matches exactly.",
210)
212SEARCH_DESCRIPTION_DEFAULT = """
213Return {media_type} that match the query.
215This endpoint allows you to search within specific fields, or to retrieve
216a collection of all {media_type} from a specific source, creator or tag.
217Results are paginated on the basis of the `page` parameter. The `page_size`
218parameter controls the total number of pages.
220Although there may be millions of relevant records, only the most relevant
221or the most recent several thousand records can be viewed. This is by design:
222the search endpoint should be used to find the top 10,000 most relevant
223results, not for exhaustive search or bulk download of every barely relevant
224result. As such, the caller should not try to access pages beyond `page_count`,
225or else the server will reject the query.
227### Default search
228The **default search** allows users to find media based on a query string.
229It supports a wide range of optional filters to narrow down search results
230according to specific needs.
232By default, this endpoint performs a full-text search for the value of `q` parameter.
233You can search within the `creator`, `title` or `tags` fields by omitting
234the `q` parameter and using one of these field parameters.
235These results can be filtered by {filter_fields}.
237The default search results are sorted by relevance.
239### Collection search
240The collection search allows to retrieve a collection of media from a specific source,
241creator or tag. The `{collection_param}` parameter is used to specify the type of collection to retrieve.
243- `{collection_param}=tag&{tag_param}=tagName` will return the media with tag `tagName`.
244- `{collection_param}=source&source=sourceName` will return the media from source `sourceName`.
245- `{collection_param}=creator&creator=creatorName&source=sourceName` will return the media by creator `creatorName` at `sourceName`.
247Collection results are sorted by the time they were added to Openverse, with the most recent
248additions appearing first. The filters such as `license` are not available for collections.
249"""
251SEARCH_DESCRIPTION_COLLECTIONS_DISABLED = """
252Search {media_type} using a query string.
254By using this endpoint, you can obtain search results based on specified
255query and optionally filter results by
256{filter_fields}.
258Results are ranked in order of relevance and paginated on the basis of the
259`page` param. The `page_size` param controls the total number of pages.
261Although there may be millions of relevant records, only the most relevant
262several thousand records can be viewed. This is by design: the search
263endpoint should be used to find the top 10,000 most relevant results, not
264for exhaustive search or bulk download of every barely relevant result. As
265such, the caller should not try to access pages beyond `page_count`, or else
266the server will reject the query."""
268SEARCH_DESCRIPTION = (
269 SEARCH_DESCRIPTION_DEFAULT
270 if settings.SHOW_COLLECTION_DOCS
271 else SEARCH_DESCRIPTION_COLLECTIONS_DISABLED
272)
274NON_FILTER_FIELDS = [
275 "q",
276 TAG,
277 COLLECTION,
278 "page",
279 "page_size",
280 "unstable__sort_by",
281 "unstable__sort_dir",
282 "unstable__authority",
283 "unstable__authority_boost",
284]