Skip to content

Commit cdd3386

Browse files
authored
feat(api-docs): Improve SDK API documentation (#6626)
1 parent 0069cff commit cdd3386

50 files changed

Lines changed: 1481 additions & 1046 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
20

api/Makefile

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ POETRY_VERSION ?= 2.2.1
1212
SAML_REVISION ?= v1.6.6
1313
RBAC_REVISION ?= v0.13.0
1414

15+
OPENAPI_FORMAT_VERSION ?= 1.23.0
16+
1517
-include .env-local
1618
-include $(DOTENV_OVERRIDE_FILE)
1719

@@ -113,10 +115,18 @@ serve-with-task-processor: TASK_RUN_METHOD=TASK_PROCESSOR
113115
serve-with-task-processor:
114116
make -j2 serve run-task-processor
115117

118+
.PHONY: generate-flagsmith-sdk-openapi
119+
generate-flagsmith-sdk-openapi: OPENAPI_SERVERS=[{"url": "https://edge.api.flagsmith.com", "description": "Flagsmith Edge API"}]
120+
generate-flagsmith-sdk-openapi:
121+
poetry run python manage.py spectacular | \
122+
npx openapi-format@${OPENAPI_FORMAT_VERSION} /dev/fd/0 \
123+
--filterFile openapi-filter-flagsmith-sdk.yml \
124+
--output ../sdk/openapi.yaml
125+
116126
.PHONY: generate-ld-client-types
117127
generate-ld-client-types:
118128
curl -sSL https://app.launchdarkly.com/api/v2/openapi.json | \
119-
npx openapi-format /dev/fd/0 \
129+
npx openapi-format@${OPENAPI_FORMAT_VERSION} /dev/fd/0 \
120130
--filterFile openapi-filter-launchdarkly.yaml | \
121131
datamodel-codegen \
122132
--output integrations/launch_darkly/types.py \
@@ -130,7 +140,7 @@ generate-ld-client-types:
130140
.PHONY: generate-grafana-client-types
131141
generate-grafana-client-types:
132142
curl -sSL https://raw.githubusercontent.com/grafana/grafana/refs/heads/main/public/openapi3.json | \
133-
npx openapi-format /dev/fd/0 \
143+
npx openapi-format@${OPENAPI_FORMAT_VERSION} /dev/fd/0 \
134144
--filterFile openapi-filter-grafana.yaml | \
135145
datamodel-codegen \
136146
--output integrations/grafana/types.py \
@@ -153,7 +163,7 @@ integrate-private-tests:
153163
rm -rf ./flagsmith-saml ./flagsmith-rbac ./flagsmith-workflows ./flagsmith-auth-controller
154164

155165
.PHONY: generate-docs
156-
generate-docs:
166+
generate-docs: generate-flagsmith-sdk-openapi
157167
poetry run flagsmith docgen metrics > ../docs/docs/administration-and-security/platform-configuration/metrics.md
158168

159169
.PHONY: add-known-sdk-version

api/api/openapi.py

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from drf_spectacular import generators, openapi
44
from drf_spectacular.extensions import (
5+
OpenApiAuthenticationExtension,
56
OpenApiSerializerExtension,
67
)
78
from drf_spectacular.plumbing import ResolvedComponent, safe_ref
89
from drf_spectacular.plumbing import append_meta as append_meta_orig
9-
from pydantic import BaseModel
10+
from pydantic import TypeAdapter
1011
from rest_framework.request import Request
12+
from typing_extensions import is_typeddict
1113

1214

1315
def append_meta(schema: dict[str, Any], meta: dict[str, Any]) -> dict[str, Any]:
@@ -135,48 +137,79 @@ def _update_security_for_mcp(self, schema: dict[str, Any]) -> dict[str, Any]:
135137
return schema
136138

137139

138-
class PydanticSchemaExtension(
139-
OpenApiSerializerExtension # type: ignore[no-untyped-call]
140-
):
140+
class TypedDictSchemaExtension(OpenApiSerializerExtension):
141141
"""
142142
An OpenAPI extension that allows drf-spectacular to generate schema documentation
143-
from Pydantic models.
143+
from TypedDicts via Pydantic.
144144
145-
This extension is automatically used when a Pydantic BaseModel subclass is passed
145+
This extension is automatically used when a TypedDict subclass is passed
146146
as a response type in @extend_schema decorators.
147147
"""
148148

149-
target_class = "pydantic.BaseModel"
150-
match_subclasses = True
149+
@classmethod
150+
def _matches(cls, target: type[Any]) -> bool:
151+
return is_typeddict(target)
151152

152153
def get_name(
153154
self,
154155
auto_schema: openapi.AutoSchema | None = None,
155156
direction: Literal["request", "response"] | None = None,
156-
) -> str | None:
157-
return self.target.__name__ # type: ignore[no-any-return]
157+
) -> str:
158+
name: str = self.target.__name__
159+
return name
158160

159161
def map_serializer(
160162
self,
161163
auto_schema: openapi.AutoSchema,
162164
direction: str,
163165
) -> dict[str, Any]:
164-
model_cls: type[BaseModel] = self.target
165-
166-
model_json_schema = model_cls.model_json_schema(
166+
model_json_schema = TypeAdapter(self.target).json_schema(
167167
mode="serialization",
168-
ref_template="#/components/schemas/{model}",
168+
ref_template="#/components/schemas/%s{model}" % self.get_name(),
169169
)
170170

171171
# Register nested definitions as components
172172
if "$defs" in model_json_schema:
173173
for ref_name, schema_kwargs in model_json_schema.pop("$defs").items():
174174
component = ResolvedComponent( # type: ignore[no-untyped-call]
175-
name=ref_name,
175+
name=self.get_name() + ref_name,
176176
type=ResolvedComponent.SCHEMA,
177177
object=ref_name,
178178
schema=schema_kwargs,
179179
)
180180
auto_schema.registry.register_on_missing(component)
181181

182182
return model_json_schema
183+
184+
185+
class EnvironmentKeyAuthenticationExtension(OpenApiAuthenticationExtension): # type: ignore[no-untyped-call]
186+
target_class = "environments.authentication.EnvironmentKeyAuthentication"
187+
name = "Environment API Key"
188+
189+
def get_security_definition(
190+
self, auto_schema: openapi.AutoSchema | None = None
191+
) -> dict[str, Any]:
192+
return {
193+
"type": "apiKey",
194+
"in": "header",
195+
"name": "X-Environment-Key",
196+
"description": "For SDK endpoints. <a href='https://docs.flagsmith.com/clients/rest#public-api-endpoints'>Find out more</a>.",
197+
}
198+
199+
200+
class MasterAPIKeyAuthenticationExtension(OpenApiAuthenticationExtension): # type: ignore[no-untyped-call]
201+
target_class = "api_keys.authentication.MasterAPIKeyAuthentication"
202+
name = "Master API Key"
203+
204+
def get_security_definition(
205+
self, auto_schema: openapi.AutoSchema | None = None
206+
) -> dict[str, Any]:
207+
return {
208+
"type": "apiKey",
209+
"in": "header",
210+
"name": "Authorization",
211+
"description": (
212+
"For Admin API endpoints. "
213+
"<a href='https://docs.flagsmith.com/clients/rest#private-api-endpoints'>Find out more</a>."
214+
),
215+
}

api/app/settings/common.py

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -534,26 +534,7 @@
534534
"SWAGGER_UI_SETTINGS": {
535535
"deepLinking": True,
536536
},
537-
"SECURITY": [
538-
{"Private": []},
539-
{"Public": []},
540-
],
541-
"APPEND_COMPONENTS": {
542-
"securitySchemes": {
543-
"Private": {
544-
"type": "apiKey",
545-
"in": "header",
546-
"name": "Authorization",
547-
"description": "For Private Endpoints. <a href='https://docs.flagsmith.com/clients/rest#private-api-endpoints'>Find out more</a>.",
548-
},
549-
"Public": {
550-
"type": "apiKey",
551-
"in": "header",
552-
"name": "X-Environment-Key",
553-
"description": "For Public Endpoints. <a href='https://docs.flagsmith.com/clients/rest#public-api-endpoints'>Find out more</a>.",
554-
},
555-
},
556-
},
537+
"SERVERS": env.json("OPENAPI_SERVERS", default=[]),
557538
"DEFAULT_GENERATOR_CLASS": "api.openapi.SchemaGenerator",
558539
"EXTENSIONS": [
559540
"api.openapi",

api/conftest.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@
8181
)
8282
from projects.tags.models import Tag
8383
from segments.models import Condition, Segment, SegmentRule
84-
from tests.test_helpers import fix_issue_3869
8584
from tests.types import (
8685
AdminClientAuthType,
8786
EnableFeaturesFixture,
@@ -101,10 +100,6 @@ def pytest_addoption(parser: pytest.Parser) -> None:
101100
)
102101

103102

104-
def pytest_sessionstart(session: pytest.Session) -> None:
105-
fix_issue_3869() # type: ignore[no-untyped-call]
106-
107-
108103
@pytest.fixture()
109104
def post_request_mock(mocker: MockerFixture) -> MagicMock:
110105
def mocked_request(*args, **kwargs) -> None: # type: ignore[no-untyped-def]

api/environments/identities/serializers.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,10 @@
66
from rest_framework.exceptions import ValidationError
77

88
from environments.identities.models import Identity
9-
from environments.identities.traits.fields import TraitValueField
109
from environments.models import Environment
1110
from environments.serializers import EnvironmentSerializerFull
1211
from features.models import FeatureState
13-
from features.serializers import (
14-
FeatureStateSerializerFull,
15-
SDKFeatureStateSerializer,
16-
)
12+
from features.serializers import FeatureStateSerializerFull
1713

1814

1915
class IdentifierOnlyIdentitySerializer(serializers.ModelSerializer): # type: ignore[type-arg]
@@ -53,16 +49,6 @@ def save(self, **kwargs): # type: ignore[no-untyped-def]
5349
return super(IdentitySerializer, self).save(**kwargs)
5450

5551

56-
class SDKIdentitiesResponseSerializer(serializers.Serializer): # type: ignore[type-arg]
57-
class _TraitSerializer(serializers.Serializer): # type: ignore[type-arg]
58-
trait_key = serializers.CharField()
59-
trait_value = TraitValueField()
60-
61-
identifier = serializers.CharField()
62-
flags = serializers.ListField(child=SDKFeatureStateSerializer())
63-
traits = serializers.ListSerializer(child=_TraitSerializer()) # type: ignore[var-annotated]
64-
65-
6652
class SDKIdentitiesQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
6753
identifier = serializers.CharField(required=True)
6854
transient = serializers.BooleanField(default=False)

api/environments/identities/views.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from django.views.decorators.cache import cache_page
1414
from django.views.decorators.vary import vary_on_headers
1515
from drf_spectacular.utils import extend_schema
16+
from flagsmith_schemas import api as api_schemas
1617
from rest_framework import status, viewsets
1718
from rest_framework.permissions import IsAuthenticated
1819
from rest_framework.response import Response
@@ -25,7 +26,6 @@
2526
from environments.identities.serializers import (
2627
IdentitySerializer,
2728
SDKIdentitiesQuerySerializer,
28-
SDKIdentitiesResponseSerializer,
2929
)
3030
from environments.models import Environment
3131
from environments.permissions.permissions import NestedEnvironmentPermissions
@@ -151,9 +151,9 @@ class SDKIdentities(SDKAPIView):
151151
throttle_classes = []
152152

153153
@extend_schema(
154-
responses={200: SDKIdentitiesResponseSerializer},
154+
responses={200: api_schemas.V1IdentitiesResponse},
155155
parameters=[SDKIdentitiesQuerySerializer],
156-
operation_id="identify_user",
156+
operation_id="sdk_v1_get_identities",
157157
)
158158
@method_decorator(vary_on_headers(SDK_ENVIRONMENT_KEY_HEADER))
159159
@method_decorator(
@@ -163,6 +163,9 @@ class SDKIdentities(SDKAPIView):
163163
)
164164
)
165165
def get(self, request): # type: ignore[no-untyped-def]
166+
"""
167+
Retrieve the flags and traits for an identity.
168+
"""
166169
identifier = request.query_params.get("identifier")
167170
if not identifier:
168171
return Response(
@@ -234,11 +237,14 @@ def get_serializer_context(self): # type: ignore[no-untyped-def]
234237
return context
235238

236239
@extend_schema(
237-
request=IdentifyWithTraitsSerializer,
238-
responses={200: SDKIdentitiesResponseSerializer},
239-
operation_id="identify_user_with_traits",
240+
request=api_schemas.V1IdentitiesRequest,
241+
responses={200: api_schemas.V1IdentitiesResponse},
242+
operation_id="sdk_v1_post_identities",
240243
)
241244
def post(self, request): # type: ignore[no-untyped-def]
245+
"""
246+
Identify a user, set their traits, and retrieve their flags.
247+
"""
242248
serializer = self.get_serializer(data=request.data)
243249
serializer.is_valid(raise_exception=True)
244250
instance = serializer.save()

api/environments/sdk/schemas.py

Lines changed: 0 additions & 10 deletions
This file was deleted.

api/environments/sdk/views.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.utils.decorators import method_decorator
55
from django.views.decorators.http import condition
66
from drf_spectacular.utils import extend_schema
7+
from flagsmith_schemas.api import V1EnvironmentDocumentResponse
78
from rest_framework.request import Request
89
from rest_framework.response import Response
910
from rest_framework.views import APIView
@@ -14,7 +15,6 @@
1415
)
1516
from environments.models import Environment
1617
from environments.permissions.permissions import EnvironmentKeyPermissions
17-
from environments.sdk.schemas import SDKEnvironmentDocumentModel
1818

1919

2020
def get_last_modified(request: Request) -> datetime | None:
@@ -30,9 +30,16 @@ class SDKEnvironmentAPIView(APIView):
3030
def get_authenticators(self): # type: ignore[no-untyped-def]
3131
return [EnvironmentKeyAuthentication(required_key_prefix="ser.")]
3232

33-
@extend_schema(responses={200: SDKEnvironmentDocumentModel})
33+
@extend_schema(
34+
responses={200: V1EnvironmentDocumentResponse},
35+
operation_id="sdk_v1_environment_document",
36+
)
3437
@method_decorator(condition(last_modified_func=get_last_modified))
3538
def get(self, request: Request) -> Response:
39+
"""
40+
Retrieve the environment document.
41+
Used by SDKs in local evaluation mode, and Edge Proxy.
42+
"""
3643
environment_document = Environment.get_environment_document(
3744
request.environment.api_key,
3845
)

api/environments/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.db.models import Count, Q, QuerySet
99
from django.utils.decorators import method_decorator
1010
from drf_spectacular.utils import OpenApiParameter, extend_schema
11+
from flagsmith_schemas.api import V1EnvironmentDocumentResponse
1112
from rest_framework import mixins, status, viewsets
1213
from rest_framework.decorators import action
1314
from rest_framework.exceptions import PermissionDenied, ValidationError
@@ -24,7 +25,6 @@
2425
EnvironmentPermissions,
2526
NestedEnvironmentPermissions,
2627
)
27-
from environments.sdk.schemas import SDKEnvironmentDocumentModel
2828
from features.versioning.models import EnvironmentFeatureVersion
2929
from features.versioning.tasks import (
3030
disable_v2_versioning,
@@ -288,7 +288,7 @@ def detailed_permissions(
288288
)
289289
return Response(serializer.data)
290290

291-
@extend_schema(responses={200: SDKEnvironmentDocumentModel})
291+
@extend_schema(responses={200: V1EnvironmentDocumentResponse})
292292
@action(detail=True, methods=["GET"], url_path="document")
293293
def get_document(self, request, api_key: str): # type: ignore[no-untyped-def]
294294
environment = (

0 commit comments

Comments
 (0)