Skip to content

Commit e79f066

Browse files
authored
docs(API): add tags to swagger and order them based on workflow (#6885)
1 parent 896067f commit e79f066

4 files changed

Lines changed: 325 additions & 1 deletion

File tree

api/api/openapi.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from typing import TYPE_CHECKING, Any, Literal
23

34
from drf_spectacular import generators, openapi
@@ -230,3 +231,152 @@ def get_security_definition(
230231
"<a href='https://docs.flagsmith.com/clients/rest#private-api-endpoints'>Find out more</a>."
231232
),
232233
}
234+
235+
236+
# Tag definitions controlling the order and display of sections in the Swagger UI.
237+
TAGS: list[dict[str, str]] = [
238+
{
239+
"name": "Authentication",
240+
"description": "Authentication, MFA, OAuth, and token management.",
241+
},
242+
{
243+
"name": "Organisations",
244+
"description": "Manage organisations, users, groups, invites, and API keys.",
245+
},
246+
{
247+
"name": "Projects",
248+
"description": "Manage projects, tags, and imports/exports.",
249+
},
250+
{
251+
"name": "Environments",
252+
"description": "Manage environments, API keys, and metrics.",
253+
},
254+
{
255+
"name": "Features",
256+
"description": "Manage features and multivariate options.",
257+
},
258+
{
259+
"name": "Feature states",
260+
"description": "Manage feature states and feature versioning.",
261+
},
262+
{
263+
"name": "Identities",
264+
"description": "Manage identities and traits.",
265+
},
266+
{
267+
"name": "Segments",
268+
"description": "Manage segments and segment rules.",
269+
},
270+
{
271+
"name": "Integrations",
272+
"description": "Configure third-party integrations (Amplitude, DataDog, Slack, etc.).",
273+
},
274+
{
275+
"name": "Permissions",
276+
"description": "Manage user and group permissions across organisations, projects, and environments.",
277+
},
278+
{
279+
"name": "Webhooks",
280+
"description": "Manage webhooks for organisations and environments.",
281+
},
282+
{
283+
"name": "Audit",
284+
"description": "Access audit logs.",
285+
},
286+
{
287+
"name": "Analytics",
288+
"description": "SDK analytics and telemetry.",
289+
},
290+
{
291+
"name": "Metadata",
292+
"description": "Manage metadata fields and model configuration.",
293+
},
294+
{
295+
"name": "Onboarding",
296+
"description": "Onboarding flows.",
297+
},
298+
{
299+
"name": "Admin dashboard",
300+
"description": "Platform hub admin dashboard endpoints.",
301+
},
302+
{
303+
"name": "sdk",
304+
"description": "SDK endpoints for flags, identities, and traits.",
305+
},
306+
{
307+
"name": "mcp",
308+
"description": "MCP-compatible endpoints.",
309+
},
310+
{
311+
"name": "experimental",
312+
"description": "Experimental endpoints subject to change.",
313+
},
314+
{
315+
"name": "Other",
316+
"description": "Other endpoints.",
317+
},
318+
]
319+
320+
# Ordered list of (regex, tag) rules for assigning tags to API operations.
321+
# The first matching rule wins, so more specific patterns must come before
322+
# broader ones (e.g. /analytics/ before /flags/).
323+
_TAG_RULES: list[tuple[re.Pattern[str], str]] = [
324+
(re.compile(r"/integrations/"), "Integrations"),
325+
(re.compile(r"/user-permissions/|/user-group-permissions/"), "Permissions"),
326+
(re.compile(r"/identities/|/edge-identities|/traits/"), "Identities"),
327+
(re.compile(r"/featurestates/|feature-version|/feature-health/"), "Feature states"),
328+
(re.compile(r"/analytics/"), "Analytics"),
329+
(re.compile(r"/features/|/multivariate/|/flags/"), "Features"),
330+
(re.compile(r"/segments/"), "Segments"),
331+
(re.compile(r"/metadata/"), "Metadata"),
332+
(re.compile(r"/audit/"), "Audit"),
333+
(re.compile(r"/webhooks?/|cb-webhook|github-webhook"), "Webhooks"),
334+
(re.compile(r"/auth/|/users/"), "Authentication"),
335+
(re.compile(r"/onboarding/"), "Onboarding"),
336+
(re.compile(r"/admin/dashboard/"), "Admin dashboard"),
337+
(re.compile(r"/environments/"), "Environments"),
338+
(re.compile(r"/organisations/"), "Organisations"),
339+
(re.compile(r"/projects/"), "Projects"),
340+
]
341+
342+
_EXCLUDED_PATHS: set[str] = {
343+
"/api/v1/swagger.json",
344+
"/api/v1/swagger.yaml",
345+
}
346+
347+
348+
def preprocessing_filter_spec(
349+
endpoints: list[tuple[str, str, str, Any]],
350+
) -> list[tuple[str, str, str, Any]]:
351+
"""Filter out internal endpoints that should not appear in the API docs."""
352+
return [
353+
(path, path_regex, method, callback)
354+
for path, path_regex, method, callback in endpoints
355+
if path not in _EXCLUDED_PATHS
356+
]
357+
358+
359+
def postprocessing_assign_tags(
360+
result: dict[str, Any], generator: Any, **kwargs: Any
361+
) -> dict[str, Any]:
362+
"""Assign descriptive tags to operations based on URL path patterns.
363+
364+
Only reassigns the default 'api' tag; operations with explicit tags
365+
(sdk, mcp, experimental, etc.) are left unchanged.
366+
"""
367+
for path, path_item in result.get("paths", {}).items():
368+
for method, operation in path_item.items():
369+
if not isinstance(operation, dict):
370+
continue
371+
tags = operation.get("tags", [])
372+
if tags != ["api"]:
373+
continue
374+
for pattern, tag in _TAG_RULES:
375+
if pattern.search(path):
376+
operation["tags"] = [tag]
377+
break
378+
else:
379+
operation["tags"] = ["Other"]
380+
381+
result["tags"] = TAGS
382+
return result

api/app/settings/common.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,13 @@
563563
"edge_api.identities.openapi",
564564
"environments.identities.traits.openapi",
565565
],
566+
"PREPROCESSING_HOOKS": [
567+
"api.openapi.preprocessing_filter_spec",
568+
],
569+
"POSTPROCESSING_HOOKS": [
570+
"drf_spectacular.hooks.postprocess_schema_enums",
571+
"api.openapi.postprocessing_assign_tags",
572+
],
566573
"ENUM_NAME_OVERRIDES": {
567574
# Overrides to use specific schema names for fields named "type".
568575
# If this is not set, drf-spectacular will generate schema names like "Type975Enum".

api/tests/unit/api/test_unit_openapi.py

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
from typing import Any
2+
3+
import pytest
14
from drf_spectacular.generators import SchemaGenerator
25
from drf_spectacular.openapi import AutoSchema
36
from typing_extensions import TypedDict
47

5-
from api.openapi import TypedDictSchemaExtension
8+
from api.openapi import (
9+
TAGS,
10+
TypedDictSchemaExtension,
11+
postprocessing_assign_tags,
12+
preprocessing_filter_spec,
13+
)
614

715

816
def test_typeddict_schema_extension__nested_typed_dict__renders_expected_schema() -> (
@@ -88,6 +96,162 @@ class ResponseModel(TypedDict):
8896
}
8997

9098

99+
@pytest.mark.parametrize(
100+
"path, expected_tag",
101+
[
102+
("/api/v1/organisations/", "Organisations"),
103+
("/api/v1/organisations/{id}/groups/", "Organisations"),
104+
("/api/v1/projects/{id}/", "Projects"),
105+
("/api/v1/environments/{api_key}/", "Environments"),
106+
("/api/v1/projects/{id}/features/", "Features"),
107+
("/api/v1/flags/{feature_id}/multivariate-options/", "Features"),
108+
("/api/v1/environments/{api_key}/featurestates/{id}/", "Feature states"),
109+
("/api/v1/environment-feature-versions/{id}/", "Feature states"),
110+
("/api/v1/environments/{api_key}/identities/{id}/", "Identities"),
111+
("/api/v1/environments/{api_key}/edge-identities/{id}/", "Identities"),
112+
("/api/v1/traits/", "Identities"),
113+
("/api/v1/segments/{id}/", "Segments"),
114+
("/api/v1/environments/{api_key}/integrations/amplitude/{id}/", "Integrations"),
115+
("/api/v1/projects/{id}/integrations/datadog/{id}/", "Integrations"),
116+
("/api/v1/organisations/{id}/integrations/github/", "Integrations"),
117+
("/api/v1/environments/{api_key}/user-permissions/{id}/", "Permissions"),
118+
("/api/v1/projects/{id}/user-group-permissions/{id}/", "Permissions"),
119+
("/api/v1/environments/{api_key}/webhooks/{id}/", "Webhooks"),
120+
("/api/v1/cb-webhook/", "Webhooks"),
121+
("/api/v1/github-webhook/", "Webhooks"),
122+
("/api/v1/audit/", "Audit"),
123+
("/api/v1/auth/login/", "Authentication"),
124+
("/api/v1/users/join/{hash}/", "Authentication"),
125+
("/api/v1/analytics/flags/", "Analytics"),
126+
("/api/v1/metadata/fields/", "Metadata"),
127+
("/api/v1/onboarding/request/send/", "Onboarding"),
128+
("/api/v1/admin/dashboard/summary/", "Admin dashboard"),
129+
],
130+
)
131+
def test_postprocessing_assign_tags__parametrized_path__assigns_correct_tag(
132+
path: str, expected_tag: str
133+
) -> None:
134+
# Given
135+
result: dict[str, Any] = {
136+
"paths": {
137+
path: {
138+
"get": {
139+
"operationId": "test_op",
140+
"tags": ["api"],
141+
},
142+
},
143+
},
144+
}
145+
146+
# When
147+
postprocessing_assign_tags(result, generator=None)
148+
149+
# Then
150+
assert result["paths"][path]["get"]["tags"] == [expected_tag]
151+
152+
153+
def test_postprocessing_assign_tags__explicit_tags__preserved() -> None:
154+
# Given
155+
result: dict[str, Any] = {
156+
"paths": {
157+
"/api/v1/flags/": {
158+
"get": {
159+
"operationId": "sdk_flags",
160+
"tags": ["sdk"],
161+
},
162+
},
163+
"/api/v1/organisations/": {
164+
"get": {
165+
"operationId": "organisations_list",
166+
"tags": ["mcp", "organisations"],
167+
},
168+
},
169+
},
170+
}
171+
172+
# When
173+
postprocessing_assign_tags(result, generator=None)
174+
175+
# Then
176+
assert result["paths"]["/api/v1/flags/"]["get"]["tags"] == ["sdk"]
177+
assert result["paths"]["/api/v1/organisations/"]["get"]["tags"] == [
178+
"mcp",
179+
"organisations",
180+
]
181+
182+
183+
def test_postprocessing_assign_tags__empty_paths__sets_tags_list_on_result() -> None:
184+
# Given
185+
result: dict[str, Any] = {"paths": {}}
186+
187+
# When
188+
postprocessing_assign_tags(result, generator=None)
189+
190+
# Then
191+
assert result["tags"] == TAGS
192+
193+
194+
def test_postprocessing_assign_tags__unmatched_path__assigned_other_tag() -> None:
195+
# Given
196+
result: dict[str, Any] = {
197+
"paths": {
198+
"/api/v1/unknown-endpoint/": {
199+
"get": {
200+
"operationId": "unknown",
201+
"tags": ["api"],
202+
},
203+
},
204+
},
205+
}
206+
207+
# When
208+
postprocessing_assign_tags(result, generator=None)
209+
210+
# Then
211+
assert result["paths"]["/api/v1/unknown-endpoint/"]["get"]["tags"] == ["Other"]
212+
213+
214+
def test_postprocessing_assign_tags__non_dict_operation__skipped() -> None:
215+
# Given - a path item with both a real operation and a non-dict entry
216+
# (e.g. path-level `parameters`, which is a list rather than an operation dict)
217+
result: dict[str, Any] = {
218+
"paths": {
219+
"/api/v1/projects/{id}/": {
220+
"parameters": [{"name": "id", "in": "path"}],
221+
"get": {
222+
"operationId": "projects_retrieve",
223+
"tags": ["api"],
224+
},
225+
},
226+
},
227+
}
228+
229+
# When
230+
postprocessing_assign_tags(result, generator=None)
231+
232+
# Then - the real operation got its tag, and the non-dict entry was left alone
233+
assert result["paths"]["/api/v1/projects/{id}/"]["get"]["tags"] == ["Projects"]
234+
assert result["paths"]["/api/v1/projects/{id}/"]["parameters"] == [
235+
{"name": "id", "in": "path"}
236+
]
237+
238+
239+
def test_preprocessing_filter_spec__swagger_endpoints__removed() -> None:
240+
# Given
241+
endpoints = [
242+
("/api/v1/organisations/", "^api/v1/organisations/", "GET", None),
243+
("/api/v1/swagger.json", "^api/v1/swagger.json", "GET", None),
244+
("/api/v1/swagger.yaml", "^api/v1/swagger.yaml", "GET", None),
245+
]
246+
247+
# When
248+
filtered = preprocessing_filter_spec(endpoints)
249+
250+
# Then
251+
assert len(filtered) == 1
252+
assert filtered[0][0] == "/api/v1/organisations/"
253+
254+
91255
def test_typeddict_schema_extension__simple_model__returns_correct_name() -> None:
92256
# Given
93257
class MyModel(TypedDict):

sdk/openapi.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,4 +563,7 @@ components:
563563
in: header
564564
name: Authorization
565565
description: Token-based authentication with required prefix "Token"
566+
tags:
567+
- name: sdk
568+
description: 'SDK endpoints for flags, identities, and traits.'
566569
$schema: 'https://spec.openapis.org/oas/3.1/dialect/base'

0 commit comments

Comments
 (0)