Browse Source

feat(api): Update put+delete client key, create get client key (#51666)

New GET - Retrieve a Client Key (not in docs previously):
![Screenshot 2023-06-27 at 11 55 23
AM](https://github.com/getsentry/sentry/assets/67301797/5fc8c2bb-6a44-4bef-9579-568720a3531e)


Old PUT:
![Screenshot 2023-06-27 at 11 56 10
AM](https://github.com/getsentry/sentry/assets/67301797/5c515165-8e66-4935-bf13-1af94fe06158)

New PUT (split into 2 screenshots):
![Screenshot 2023-06-27 at 4 20 38
PM](https://github.com/getsentry/sentry/assets/67301797/565e6909-958a-46ce-900d-58d9ac38ceb2)
![Screenshot 2023-06-27 at 4 20 57
PM](https://github.com/getsentry/sentry/assets/67301797/164127ca-2635-42ca-9119-cd852caac536)


Old DELETE:
![Screenshot 2023-06-27 at 11 39 51
AM](https://github.com/getsentry/sentry/assets/67301797/e7a1dbf5-2987-4a1d-a1b1-ec62321ce2d5)

New DELETE:
![Screenshot 2023-06-27 at 11 40 06
AM](https://github.com/getsentry/sentry/assets/67301797/64b95655-27c7-4b73-b4a1-aa5e3eb02993)
Seiji Chew 1 year ago
parent
commit
71392a1f2a

+ 0 - 3
api-docs/openapi.json

@@ -141,9 +141,6 @@
     "/api/0/projects/{organization_slug}/{project_slug}/user-feedback/": {
       "$ref": "paths/projects/user-feedback.json"
     },
-    "/api/0/projects/{organization_slug}/{project_slug}/keys/{key_id}/": {
-      "$ref": "paths/projects/key-details.json"
-    },
     "/api/0/projects/{organization_slug}/{project_slug}/hooks/": {
       "$ref": "paths/projects/service-hooks.json"
     },

+ 0 - 157
api-docs/paths/projects/key-details.json

@@ -1,157 +0,0 @@
-{
-  "put": {
-    "tags": ["Projects"],
-    "description": "Update a client key.  This can be used to rename a key.",
-    "operationId": "Update a Client Key",
-    "parameters": [
-      {
-        "name": "organization_slug",
-        "in": "path",
-        "description": "The slug of the organization the client keys belong to.",
-        "required": true,
-        "schema": {
-          "type": "string"
-        }
-      },
-      {
-        "name": "project_slug",
-        "in": "path",
-        "description": "The slug of the project the client keys belong to.",
-        "required": true,
-        "schema": {
-          "type": "string"
-        }
-      },
-      {
-        "name": "key_id",
-        "in": "path",
-        "description": "The ID of the key to update.",
-        "required": true,
-        "schema": {
-          "type": "string"
-        }
-      }
-    ],
-    "requestBody": {
-      "content": {
-        "application/json": {
-          "schema": {
-            "type": "object",
-            "properties": {
-              "name": {
-                "type": "string",
-                "description": "The new name for the client key."
-              }
-            }
-          },
-          "example": {
-            "name": "Fluffy Key"
-          }
-        }
-      },
-      "required": true
-    },
-    "responses": {
-      "200": {
-        "description": "Success",
-        "content": {
-          "application/json": {
-            "schema": {
-              "$ref": "../../components/schemas/key.json#/Key"
-            },
-            "example": {
-              "browserSdk": {
-                "choices": [
-                  ["latest", "latest"],
-                  ["4.x", "4.x"]
-                ]
-              },
-              "browserSdkVersion": "4.x",
-              "dateCreated": "2018-11-06T21:20:07.941Z",
-              "dsn": {
-                "cdn": "https://sentry.io/js-sdk-loader/cec9dfceb0b74c1c9a5e3c135585f364.min.js",
-                "csp": "https://sentry.io/api/2/csp-report/?sentry_key=cec9dfceb0b74c1c9a5e3c135585f364",
-                "minidump": "https://sentry.io/api/2/minidump/?sentry_key=cec9dfceb0b74c1c9a5e3c135585f364",
-                "public": "https://cec9dfceb0b74c1c9a5e3c135585f364@sentry.io/2",
-                "secret": "https://cec9dfceb0b74c1c9a5e3c135585f364:4f6a592349e249c5906918393766718d@sentry.io/2",
-                "security": "https://sentry.io/api/2/security/?sentry_key=cec9dfceb0b74c1c9a5e3c135585f364"
-              },
-              "id": "cec9dfceb0b74c1c9a5e3c135585f364",
-              "isActive": true,
-              "label": "Fluffy Key",
-              "name": "Fluffy Key",
-              "projectId": 2,
-              "public": "cec9dfceb0b74c1c9a5e3c135585f364",
-              "rateLimit": null,
-              "secret": "4f6a592349e249c5906918393766718d"
-            }
-          }
-        }
-      },
-      "400": {
-        "description": "Bad Input"
-      },
-      "403": {
-        "description": "Forbidden"
-      },
-      "404": {
-        "description": "Project not found"
-      }
-    },
-    "security": [
-      {
-        "auth_token": ["project:write"]
-      }
-    ]
-  },
-  "delete": {
-    "tags": ["Projects"],
-    "description": "Delete a client key.",
-    "operationId": "Delete a Client Key",
-    "parameters": [
-      {
-        "name": "organization_slug",
-        "in": "path",
-        "description": "The slug of the organization the client keys belong to.",
-        "required": true,
-        "schema": {
-          "type": "string"
-        }
-      },
-      {
-        "name": "project_slug",
-        "in": "path",
-        "description": "The slug of the project the client keys belong to.",
-        "required": true,
-        "schema": {
-          "type": "string"
-        }
-      },
-      {
-        "name": "key_id",
-        "in": "path",
-        "description": "The ID of the key to delete.",
-        "required": true,
-        "schema": {
-          "type": "string"
-        }
-      }
-    ],
-    "responses": {
-      "204": {
-        "description": "Success"
-      },
-      "403": {
-        "description": "Forbidden"
-      },
-      "404": {
-        "description": "Project not found"
-      }
-    },
-    "security": [
-      {
-        "auth_token": ["project:admin"]
-      }
-    ]
-  }
-}

+ 2 - 2
src/sentry/api/endpoints/project_filter_details.py

@@ -42,7 +42,7 @@ class ProjectFilterDetailsEndpoint(ProjectEndpoint):
             },
         ),
         responses={
-            201: RESPONSE_NO_CONTENT,
+            204: RESPONSE_NO_CONTENT,
             400: RESPONSE_BAD_REQUEST,
             403: RESPONSE_FORBIDDEN,
             404: RESPONSE_NOT_FOUND,
@@ -114,4 +114,4 @@ class ProjectFilterDetailsEndpoint(ProjectEndpoint):
             data={"state": returned_state},
         )
 
-        return Response(status=201)
+        return Response(status=204)

+ 114 - 73
src/sentry/api/endpoints/project_key_details.py

@@ -1,4 +1,5 @@
 from django.db.models import F
+from drf_spectacular.utils import extend_schema
 from rest_framework import status
 from rest_framework.request import Request
 from rest_framework.response import Response
@@ -8,14 +9,44 @@ from sentry.api.base import region_silo_endpoint
 from sentry.api.bases.project import ProjectEndpoint
 from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.serializers import serialize
+from sentry.api.serializers.models.project_key import ProjectKeySerializer
 from sentry.api.serializers.rest_framework import ProjectKeyRequestSerializer
+from sentry.apidocs.constants import (
+    RESPONSE_BAD_REQUEST,
+    RESPONSE_FORBIDDEN,
+    RESPONSE_NO_CONTENT,
+    RESPONSE_NOT_FOUND,
+)
+from sentry.apidocs.examples.project_examples import ProjectExamples
+from sentry.apidocs.parameters import GlobalParams, ProjectParams
 from sentry.loader.browsersdkversion import get_default_sdk_version_for_project
 from sentry.models import ProjectKey, ProjectKeyStatus
 
 
+@extend_schema(tags=["Projects"])
 @region_silo_endpoint
 class ProjectKeyDetailsEndpoint(ProjectEndpoint):
+    public = {"GET", "PUT", "DELETE"}
+
+    @extend_schema(
+        operation_id="Retrieve a Client Key",
+        parameters=[
+            GlobalParams.ORG_SLUG,
+            GlobalParams.PROJECT_SLUG,
+            ProjectParams.key_id("The ID of the client key"),
+        ],
+        request=None,
+        responses={
+            200: ProjectKeySerializer,
+            403: RESPONSE_FORBIDDEN,
+            404: RESPONSE_NOT_FOUND,
+        },
+        examples=ProjectExamples.BASE_KEY,
+    )
     def get(self, request: Request, project, key_id) -> Response:
+        """
+        Return a client key bound to a project.
+        """
         try:
             key = ProjectKey.objects.get(
                 project=project, public_key=key_id, roles=F("roles").bitor(ProjectKey.roles.store)
@@ -25,20 +56,30 @@ class ProjectKeyDetailsEndpoint(ProjectEndpoint):
 
         return Response(serialize(key, request.user), status=200)
 
+    @extend_schema(
+        operation_id="Update a Client Key",
+        parameters=[
+            GlobalParams.ORG_SLUG,
+            GlobalParams.PROJECT_SLUG,
+            ProjectParams.key_id("The ID of the key to update."),
+            GlobalParams.name("The name for the client key"),
+            ProjectParams.IS_ACTIVE,
+            ProjectParams.RATE_LIMIT,
+            ProjectParams.BROWSER_SDK_VERSION,
+            ProjectParams.DYNAMIC_SDK_LOADER_OPTIONS,
+        ],
+        request=ProjectKeyRequestSerializer,
+        responses={
+            200: ProjectKeySerializer,
+            400: RESPONSE_BAD_REQUEST,
+            403: RESPONSE_FORBIDDEN,
+            404: RESPONSE_NOT_FOUND,
+        },
+        examples=ProjectExamples.BASE_KEY,
+    )
     def put(self, request: Request, project, key_id) -> Response:
         """
-        Update a Client Key
-        ```````````````````
-
-        Update a client key.  This can be used to rename a key.
-
-        :pparam string organization_slug: the slug of the organization the
-                                          client keys belong to.
-        :pparam string project_slug: the slug of the project the client keys
-                                     belong to.
-        :pparam string key_id: the ID of the key to update.
-        :param string name: the new name for the client key.
-        :auth: required
+        Update a client key.
         """
         try:
             key = ProjectKey.objects.get(
@@ -50,74 +91,74 @@ class ProjectKeyDetailsEndpoint(ProjectEndpoint):
         serializer = ProjectKeyRequestSerializer(data=request.data, partial=True)
         default_version = get_default_sdk_version_for_project(project)
 
-        if serializer.is_valid():
-            result = serializer.validated_data
+        if not serializer.is_valid():
+            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+        result = serializer.validated_data
 
-            if result.get("name"):
-                key.label = result["name"]
-
-            if not key.data:
-                key.data = {}
+        if result.get("name"):
+            key.label = result["name"]
+        if not key.data:
+            key.data = {}
+        key.data["browserSdkVersion"] = (
+            default_version if not result.get("browserSdkVersion") else result["browserSdkVersion"]
+        )
 
-            key.data["browserSdkVersion"] = (
-                default_version
-                if not result.get("browserSdkVersion")
-                else result["browserSdkVersion"]
-            )
+        result_dynamic_sdk_options = result.get("dynamicSdkLoaderOptions")
+        if result_dynamic_sdk_options:
+            if key.data.get("dynamicSdkLoaderOptions"):
+                key.data["dynamicSdkLoaderOptions"].update(result_dynamic_sdk_options)
+            else:
+                key.data["dynamicSdkLoaderOptions"] = result_dynamic_sdk_options
+
+        if result.get("isActive") is True:
+            key.status = ProjectKeyStatus.ACTIVE
+        elif result.get("isActive") is False:
+            key.status = ProjectKeyStatus.INACTIVE
+
+        if features.has("projects:rate-limits", project):
+            ratelimit = result.get("rateLimit", -1)
+            if (
+                ratelimit is None
+                or ratelimit != -1
+                and ratelimit
+                and (ratelimit["count"] is None or ratelimit["window"] is None)
+            ):
+                key.rate_limit_count = None
+                key.rate_limit_window = None
+            elif result.get("rateLimit"):
+                key.rate_limit_count = result["rateLimit"]["count"]
+                key.rate_limit_window = result["rateLimit"]["window"]
+
+        key.save()
 
-            result_dynamic_sdk_options = result.get("dynamicSdkLoaderOptions")
-
-            if result_dynamic_sdk_options:
-                if key.data.get("dynamicSdkLoaderOptions"):
-                    key.data["dynamicSdkLoaderOptions"].update(result_dynamic_sdk_options)
-                else:
-                    key.data["dynamicSdkLoaderOptions"] = result_dynamic_sdk_options
-
-            if result.get("isActive") is True:
-                key.status = ProjectKeyStatus.ACTIVE
-            elif result.get("isActive") is False:
-                key.status = ProjectKeyStatus.INACTIVE
-
-            if features.has("projects:rate-limits", project):
-                ratelimit = result.get("rateLimit", -1)
-                if (
-                    ratelimit is None
-                    or ratelimit != -1
-                    and ratelimit
-                    and (ratelimit["count"] is None or ratelimit["window"] is None)
-                ):
-                    key.rate_limit_count = None
-                    key.rate_limit_window = None
-                elif result.get("rateLimit"):
-                    key.rate_limit_count = result["rateLimit"]["count"]
-                    key.rate_limit_window = result["rateLimit"]["window"]
-
-            key.save()
-
-            self.create_audit_entry(
-                request=request,
-                organization=project.organization,
-                target_object=key.id,
-                event=audit_log.get_event_id("PROJECTKEY_EDIT"),
-                data=key.get_audit_log_data(),
-            )
+        self.create_audit_entry(
+            request=request,
+            organization=project.organization,
+            target_object=key.id,
+            event=audit_log.get_event_id("PROJECTKEY_EDIT"),
+            data=key.get_audit_log_data(),
+        )
 
-            return Response(serialize(key, request.user), status=200)
-        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+        return Response(serialize(key, request.user), status=200)
 
+    @extend_schema(
+        operation_id="Delete a Client Key",
+        parameters=[
+            GlobalParams.ORG_SLUG,
+            GlobalParams.PROJECT_SLUG,
+            ProjectParams.key_id("The ID of the key to delete."),
+        ],
+        request=None,
+        responses={
+            204: RESPONSE_NO_CONTENT,
+            403: RESPONSE_FORBIDDEN,
+            404: RESPONSE_NOT_FOUND,
+        },
+        examples=None,
+    )
     def delete(self, request: Request, project, key_id) -> Response:
         """
-        Delete a Client Key
-        ```````````````````
-
-        Delete a client key.
-
-        :pparam string organization_slug: the slug of the organization the
-                                          client keys belong to.
-        :pparam string project_slug: the slug of the project the client keys
-                                     belong to.
-        :pparam string key_id: the ID of the key to delete.
-        :auth: required
+        Delete a client key for a given project.
         """
         try:
             key = ProjectKey.objects.get(

+ 1 - 1
src/sentry/api/endpoints/project_keys.py

@@ -81,7 +81,7 @@ class ProjectKeysEndpoint(ProjectEndpoint):
             400: RESPONSE_BAD_REQUEST,
             403: RESPONSE_FORBIDDEN,
         },
-        examples=ProjectExamples.CREATE_CLIENT_KEY,
+        examples=ProjectExamples.BASE_KEY,
     )
     def post(self, request: Request, project) -> Response:
         """

+ 4 - 1
src/sentry/apidocs/constants.py

@@ -4,11 +4,14 @@ from drf_spectacular.utils import OpenApiResponse
 RESPONSE_SUCCESS = OpenApiResponse(description="Success")
 
 # 201 - Created
-RESPONSE_NO_CONTENT = OpenApiResponse(description="No Content")
+RESPONSE_CREATED = OpenApiResponse(description="Created")
 
 # 202 - Accepted (not yet acted on fully)
 RESPONSE_ACCEPTED = OpenApiResponse(description="Accepted")
 
+# 204 No Content
+RESPONSE_NO_CONTENT = OpenApiResponse(description="No Content")
+
 # 208
 RESPONSE_ALREADY_REPORTED = OpenApiResponse(description="Already Reported")
 

+ 28 - 19
src/sentry/apidocs/examples/project_examples.py

@@ -8,15 +8,15 @@ key_with_rate_limiting = {
     "secret": "189485c3b8ccf582bf5e12c530ef8858",
     "projectId": 4505281256090153,
     "isActive": True,
-    "rateLimit": {"window": 300, "count": 1000},
+    "rateLimit": {"window": 7200, "count": 1000},
     "dsn": {
-        "secret": "https://a785682ddda742d7a8a4088810d75598:bcd99b3790b3441c85ce4b1eaa854f66@o4504765715316736.ingest.sentry.io/4505281231978496",
-        "public": "https://a785682ddda742d7a8a4088810d75598@o4504765715316736.ingest.sentry.io/4505281231978496",
-        "csp": "https://o4504765715316736.ingest.sentry.io/api/4505281231978496/csp-report/?sentry_key=a785682ddda742d7a8a4088810d75598",
-        "security": "https://o4504765715316736.ingest.sentry.io/api/4505281231978496/security/?sentry_key=a785682ddda742d7a8a4088810d75598",
-        "minidump": "https://o4504765715316736.ingest.sentry.io/api/4505281231978496/minidump/?sentry_key=a785682ddda742d7a8a4088810d75598",
-        "unreal": "https://o4504765715316736.ingest.sentry.io/api/4505281231978496/unreal/a785682ddda742d7a8a4088810d75598/",
-        "cdn": "https://js.sentry-cdn.com/a785682ddda742d7a8a4088810d75598.min.js",
+        "secret": "https://a785682ddda742d7a8a4088810e67701:bcd99b3790b3441c85ce4b1eaa854f66@o4504765715316736.ingest.sentry.io/4505281256090153",
+        "public": "https://a785682ddda742d7a8a4088810e67791@o4504765715316736.ingest.sentry.io/4505281256090153",
+        "csp": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/csp-report/?sentry_key=a785682ddda719b7a8a4011110d75598",
+        "security": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/security/?sentry_key=a785682ddda719b7a8a4011110d75598",
+        "minidump": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/minidump/?sentry_key=a785682ddda719b7a8a4011110d75598",
+        "unreal": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/unreal/a785682ddda719b7a8a4011110d75598/",
+        "cdn": "https://js.sentry-cdn.com/a785682ddda719b7a8a4011110d75598.min.js",
     },
     "browserSdkVersion": "7.x",
     "browserSdk": {"choices": [["latest", "latest"], ["7.x", "7.x"]]},
@@ -24,7 +24,7 @@ key_with_rate_limiting = {
     "dynamicSdkLoaderOptions": {
         "hasReplay": True,
         "hasPerformance": True,
-        "hasDebug": False,
+        "hasDebug": True,
     },
 }
 
@@ -38,13 +38,13 @@ key_wo_rate_limiting = {
     "isActive": True,
     "rateLimit": None,
     "dsn": {
-        "secret": "https://0bdfcfcaff6547f8923ee34dcfaef1f6:64e7727c85434bdfbbb706409b893339@o4504765715316736.ingest.sentry.io/4505281231978496",
-        "public": "https://0bdfcfcaff6547f8923ee34dcfaef1f6@o4504765715316736.ingest.sentry.io/4505281231978496",
-        "csp": "https://o4504765715316736.ingest.sentry.io/api/4505281231978496/csp-report/?sentry_key=0bdfcfcaff6547f8923ee34dcfaef1f6",
-        "security": "https://o4504765715316736.ingest.sentry.io/api/4505281231978496/security/?sentry_key=0bdfcfcaff6547f8923ee34dcfaef1f6",
-        "minidump": "https://o4504765715316736.ingest.sentry.io/api/4505281231978496/minidump/?sentry_key=0bdfcfcaff6547f8923ee34dcfaef1f6",
-        "unreal": "https://o4504765715316736.ingest.sentry.io/api/4505281231978496/unreal/0bdfcfcaff6547f8923ee34dcfaef1f6/",
-        "cdn": "https://js.sentry-cdn.com/0bdfcfcaff6547f8923ee34dcfaef1f6.min.js",
+        "secret": "https://a785682ddda742d7a8a4088810e67701:bcd99b3790b3441c85ce4b1eaa854f66@o4504765715316736.ingest.sentry.io/4505281256090153",
+        "public": "https://a785682ddda742d7a8a4088810e67791@o4504765715316736.ingest.sentry.io/4505281256090153",
+        "csp": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/csp-report/?sentry_key=a785682ddda719b7a8a4011110d75598",
+        "security": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/security/?sentry_key=a785682ddda719b7a8a4011110d75598",
+        "minidump": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/minidump/?sentry_key=a785682ddda719b7a8a4011110d75598",
+        "unreal": "https://o4504765715316736.ingest.sentry.io/api/4505281256090153/unreal/a785682ddda719b7a8a4011110d75598/",
+        "cdn": "https://js.sentry-cdn.com/a785682ddda719b7a8a4011110d75598.min.js",
     },
     "browserSdkVersion": "7.x",
     "browserSdk": {"choices": [["latest", "latest"], ["7.x", "7.x"]]},
@@ -112,11 +112,11 @@ project = {
 
 
 class ProjectExamples:
-    CREATE_CLIENT_KEY = [
+    BASE_KEY = [
         OpenApiExample(
-            "Create a New Client Key",
+            "Client key with rate limiting",
             value=key_with_rate_limiting,
-            status_codes=["201"],
+            status_codes=["200", "201"],
             response_only=True,
         ),
     ]
@@ -141,3 +141,12 @@ class ProjectExamples:
             response_only=True,
         ),
     ]
+
+    RETREVE_CLIENT_KEY = [
+        OpenApiExample(
+            "Retrieve an Existing Client Key",
+            value=key_wo_rate_limiting,
+            status_codes=["200"],
+            response_only=True,
+        ),
+    ]

+ 88 - 3
src/sentry/apidocs/parameters.py

@@ -1,6 +1,6 @@
 from drf_spectacular.plumbing import build_array_type, build_basic_type
 from drf_spectacular.types import OpenApiTypes
-from drf_spectacular.utils import OpenApiParameter
+from drf_spectacular.utils import OpenApiParameter, inline_serializer
 from rest_framework import serializers
 
 # NOTE: Please add new params by path vs query, then in alphabetical order
@@ -248,6 +248,18 @@ incorrect or missing.
         description="Toggle the browser-extensions, localhost, or web-crawlers filter on or off.",
     )
 
+    BROWSER_SDK_VERSION = OpenApiParameter(
+        name="browserSdkVersion",
+        location="query",
+        required=False,
+        type=str,
+        description="""
+The Sentry Javascript SDK version to use. The currently supported options are:
+- `7.x`
+- `latest`
+""",
+    )
+
     DEFAULT_RULES = OpenApiParameter(
         name="default_rules",
         location="query",
@@ -256,13 +268,76 @@ incorrect or missing.
         description="Defaults to true where the behavior is to alert the user on every new issue. Setting this to false will turn this off and the user must create their own alerts to be notified of new issues.",
     )
 
+    DYNAMIC_SDK_LOADER_OPTIONS = OpenApiParameter(
+        name="dynamicSdkLoaderOptions",
+        location="query",
+        required=False,
+        type=inline_serializer(
+            name="DynamicSDKLoaderOptionsSerializer",
+            fields={
+                "hasReplay": serializers.BooleanField(required=False),
+                "hasPerformance": serializers.BooleanField(required=False),
+                "hasDebug": serializers.BooleanField(required=False),
+            },
+        ),
+        description="""
+Configures multiple options for the Javascript Loader Script.
+- `Performance Monitoring`
+- `Debug Bundles & Logging`
+- `Session Replay`: Note that the loader will load the ES6 bundle instead of the ES5 bundle.
+```json
+{
+    "dynamicSdkLoaderOptions": {
+        "hasReplay": true,
+        "hasPerformance": true,
+        "hasDebug": true
+    }
+}
+```
+""",
+    )
+
+    IS_ACTIVE = OpenApiParameter(
+        name="isActive",
+        location="query",
+        required=False,
+        type=bool,
+        description="Activate or deactivate the client key.",
+    )
+
+    RATE_LIMIT = OpenApiParameter(
+        name="rateLimit",
+        location="query",
+        required=False,
+        type=inline_serializer(
+            name="RateLimitParameterSerializer",
+            fields={
+                "window": serializers.IntegerField(required=False),
+                "count": serializers.IntegerField(required=False),
+            },
+        ),
+        description="""
+Applies a rate limit to cap the number of errors accepted during a given time window. To
+disable entirely set `rateLimit` to null.
+```json
+{
+    "rateLimit": {
+        "window": 7200, // time in seconds
+        "count": 1000 // error cap
+    }
+}
+```
+        """,
+    )
+
     SUB_FILTERS = OpenApiParameter(
         name="subfilters",
         location="query",
         required=False,
         type=build_typed_list(OpenApiTypes.STR),
-        description="""A list specifying which legacy browser filters should be active. Anything excluded from
-                    the list will be turned off. The options are:
+        description="""
+A list specifying which legacy browser filters should be active. Anything excluded from the list
+will be turned off. The options are:
 - `ie_pre_9`: Internet Explorer Version 8 and lower
 - `ie9`: Internet Explorer Version 9
 - `ie10`: Internet Explorer Version 10
@@ -274,6 +349,16 @@ incorrect or missing.
 """,
     )
 
+    @staticmethod
+    def key_id(description: str) -> OpenApiParameter:
+        return OpenApiParameter(
+            name="key_id",
+            location="path",
+            required=True,
+            type=str,
+            description=description,
+        )
+
     @staticmethod
     def platform(description: str) -> OpenApiParameter:
         return OpenApiParameter(

+ 5 - 5
tests/sentry/api/endpoints/test_project_filter_details.py

@@ -19,7 +19,7 @@ class ProjectFilterDetailsTest(APITestCase):
 
         project.update_option("filters:browser-extensions", "0")
         self.get_success_response(
-            org.slug, project.slug, "browser-extensions", active=True, status_code=201
+            org.slug, project.slug, "browser-extensions", active=True, status_code=204
         )
 
         assert project.get_option("filters:browser-extensions") == "1"
@@ -35,7 +35,7 @@ class ProjectFilterDetailsTest(APITestCase):
         project.update_option("filters:health-check", "0")
         with Feature("organizations:health-check-filter"):
             self.get_success_response(
-                org.slug, project.slug, "health-check", active=True, status_code=201
+                org.slug, project.slug, "health-check", active=True, status_code=204
             )
         # option was changed by the request
         assert project.get_option("filters:health-check") == "1"
@@ -43,7 +43,7 @@ class ProjectFilterDetailsTest(APITestCase):
         project.update_option("filters:health-check", "1")
         with Feature("organizations:health-check-filter"):
             self.get_success_response(
-                org.slug, project.slug, "health-check", active=False, status_code=201
+                org.slug, project.slug, "health-check", active=False, status_code=204
             )
         # option was changed by the request
         assert project.get_option("filters:health-check") == "0"
@@ -60,7 +60,7 @@ class ProjectFilterDetailsTest(APITestCase):
         project.update_option("filters:health-check", "0")
         with Feature({"organizations:health-check-filter": False}):
             resp = self.get_response(
-                org.slug, project.slug, "health-check", active=True, status_code=201
+                org.slug, project.slug, "health-check", active=True, status_code=204
             )
         # check we return error
         assert resp.status_code == 404
@@ -99,7 +99,7 @@ class ProjectFilterDetailsTest(APITestCase):
             project.slug,
             "legacy-browsers",
             subfilters=new_subfilters,
-            status_code=201,
+            status_code=204,
         )
 
         assert set(project.get_option("filters:legacy-browsers")) == set(new_subfilters)