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

1from http.client import responses as http_responses 

2from textwrap import dedent 

3 

4from django.conf import settings 

5from rest_framework.exceptions import ( 

6 APIException, 

7 NotFound, 

8 ValidationError, 

9) 

10 

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) 

19 

20from api.constants.media_types import MediaType 

21from api.constants.parameters import COLLECTION, TAG 

22 

23 

24def fields_to_md(field_names): 

25 """ 

26 Create a Markdown representation of the given list of names to use in Swagger docs. 

27 

28 :param field_names: the list of field names to convert to Markdown 

29 :return: the names as a Markdown string 

30 """ 

31 

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}`" 

35 

36 

37class APIExceptionOpenApiSerializerExtension(OpenApiSerializerExtension): 

38 target_class = APIException 

39 match_subclasses = True 

40 

41 @classmethod 

42 def _get_detail(cls, target): 

43 return getattr(target, "detail", target.default_detail) 

44 

45 def get_name(self, *args): 

46 cls = self.target if isinstance(self.target, type) else self.target.__class__ 

47 return cls.__name__ 

48 

49 def map_serializer(self, *args): 

50 cls = self.target if isinstance(self.target, type) else self.target.__class__ 

51 

52 detail_string = { 

53 "type": "string", 

54 "description": "A description of what went wrong.", 

55 } 

56 

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 } 

73 

74 return { 

75 "title": cls.__name__, 

76 "type": "object", 

77 "properties": {"detail": detail_string}, 

78 } 

79 

80 @classmethod 

81 def exception_example(cls, exception): 

82 if exception == ValidationError: 

83 return {"detail": {"<request parameter>": "<error details>"}} 

84 

85 return {"detail": cls._get_detail(exception)} 

86 

87 

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 [] 

99 

100 return [ 

101 OpenApiExample( 

102 http_responses[code], 

103 value=example, 

104 ) 

105 ] 

106 

107 

108def custom_extend_schema(**kwargs): 

109 extend_args = {} 

110 

111 description = kwargs.pop("desc", None) 

112 if description: 

113 description = dedent(description) 

114 extend_args["description"] = f"{description}" 

115 

116 parameters = kwargs.pop("params", []) 

117 if not isinstance(parameters, list): 

118 parameters = [parameters] 

119 if parameters: 

120 extend_args["parameters"] = parameters 

121 

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 

133 

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 } 

140 

141 return extend_schema(**extend_args, **kwargs) 

142 

143 

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 """ 

149 

150 def get_description(self) -> str: 

151 return f"""{super().get_description()}""" 

152 

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) 

161 

162 

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) 

173 

174 

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 ) 

180 

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 ) 

190 

191 

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) 

211 

212SEARCH_DESCRIPTION_DEFAULT = """ 

213Return {media_type} that match the query. 

214 

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. 

219 

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. 

226 

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. 

231 

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}. 

236 

237The default search results are sorted by relevance. 

238 

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. 

242 

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`. 

246 

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""" 

250 

251SEARCH_DESCRIPTION_COLLECTIONS_DISABLED = """ 

252Search {media_type} using a query string. 

253 

254By using this endpoint, you can obtain search results based on specified 

255query and optionally filter results by 

256{filter_fields}. 

257 

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. 

260 

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.""" 

267 

268SEARCH_DESCRIPTION = ( 

269 SEARCH_DESCRIPTION_DEFAULT 

270 if settings.SHOW_COLLECTION_DOCS 

271 else SEARCH_DESCRIPTION_COLLECTIONS_DISABLED 

272) 

273 

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]