api: Clean schema up more (#17055)

* api: better filtering

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* revamp prompt

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add common query param to dedupe

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* simplify paginated results

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* simplify error responses

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* keep error schemas

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better structure

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ok simplifying too far

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix web

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove unused optimization

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-gen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2025-10-08 22:35:10 +02:00
committed by GitHub
parent f09e9d9d45
commit bbf77002d5
14 changed files with 2393 additions and 11056 deletions
+7 -42
View File
@@ -1,44 +1,10 @@
"""Pagination which includes total pages and current page"""
from drf_spectacular.plumbing import build_object_type
from rest_framework import pagination
from rest_framework.response import Response
PAGINATION_COMPONENT_NAME = "Pagination"
PAGINATION_SCHEMA = {
"type": "object",
"properties": {
"next": {
"type": "number",
},
"previous": {
"type": "number",
},
"count": {
"type": "number",
},
"current": {
"type": "number",
},
"total_pages": {
"type": "number",
},
"start_index": {
"type": "number",
},
"end_index": {
"type": "number",
},
},
"required": [
"next",
"previous",
"count",
"current",
"total_pages",
"start_index",
"end_index",
],
}
from authentik.api.v3.schema.response import PAGINATION
class Pagination(pagination.PageNumberPagination):
@@ -70,14 +36,13 @@ class Pagination(pagination.PageNumberPagination):
)
def get_paginated_response_schema(self, schema):
return {
"type": "object",
"properties": {
"pagination": {"$ref": f"#/components/schemas/{PAGINATION_COMPONENT_NAME}"},
return build_object_type(
properties={
"pagination": PAGINATION.ref,
"results": schema,
},
"required": ["pagination", "results"],
}
required=["pagination", "results"],
)
class SmallerPagination(Pagination):
+40 -130
View File
@@ -3,53 +3,23 @@
from collections.abc import Callable
from typing import Any
from django.utils.translation import gettext_lazy as _
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.plumbing import (
ResolvedComponent,
build_array_type,
build_basic_type,
build_object_type,
)
from drf_spectacular.plumbing import ResolvedComponent
from drf_spectacular.renderers import OpenApiJsonRenderer
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from rest_framework.settings import api_settings
from structlog.stdlib import get_logger
from authentik.api.apps import AuthentikAPIConfig
from authentik.api.pagination import PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA
GENERIC_ERROR = build_object_type(
description=_("Generic API Error"),
properties={
"detail": build_basic_type(OpenApiTypes.STR),
"code": build_basic_type(OpenApiTypes.STR),
},
required=["detail"],
)
VALIDATION_ERROR = build_object_type(
description=_("Validation Error"),
properties={
api_settings.NON_FIELD_ERRORS_KEY: build_array_type(build_basic_type(OpenApiTypes.STR)),
"code": build_basic_type(OpenApiTypes.STR),
},
required=[],
additionalProperties={},
from authentik.api.v3.schema.query import QUERY_PARAMS
from authentik.api.v3.schema.response import (
GENERIC_ERROR,
GENERIC_ERROR_RESPONSE,
PAGINATION,
VALIDATION_ERROR,
VALIDATION_ERROR_RESPONSE,
)
def create_component(
generator: SchemaGenerator, name: str, schema: Any, type_=ResolvedComponent.SCHEMA
) -> ResolvedComponent:
"""Register a component and return a reference to it."""
component = ResolvedComponent(
name=name,
type=type_,
schema=schema,
object=name,
)
generator.registry.register_on_missing(component)
return component
LOGGER = get_logger()
def preprocess_schema_exclude_non_api(endpoints: list[tuple[str, Any, Any, Callable]], **kwargs):
@@ -61,45 +31,30 @@ def preprocess_schema_exclude_non_api(endpoints: list[tuple[str, Any, Any, Calla
]
def postprocess_schema_register(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Register custom schema components"""
LOGGER.debug("Registering custom schemas")
generator.registry.register_on_missing(PAGINATION)
generator.registry.register_on_missing(GENERIC_ERROR)
generator.registry.register_on_missing(GENERIC_ERROR_RESPONSE)
generator.registry.register_on_missing(VALIDATION_ERROR)
generator.registry.register_on_missing(VALIDATION_ERROR_RESPONSE)
for query in QUERY_PARAMS.values():
generator.registry.register_on_missing(query)
return result
def postprocess_schema_responses(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Workaround to set a default response for endpoints.
Workaround suggested at
<https://github.com/tfranzel/drf-spectacular/issues/119#issuecomment-656970357>
for the missing drf-spectacular feature discussed in
<https://github.com/tfranzel/drf-spectacular/issues/101>.
"""
create_component(generator, PAGINATION_COMPONENT_NAME, PAGINATION_SCHEMA)
generic_error = create_component(generator, "GenericError", GENERIC_ERROR)
validation_error = create_component(generator, "ValidationError", VALIDATION_ERROR)
"""Default error responses"""
LOGGER.debug("Adding default error responses")
for path in result["paths"].values():
for method in path.values():
method["responses"].setdefault(
"400",
{
"content": {
"application/json": {
"schema": validation_error.ref,
}
},
"description": "",
},
)
method["responses"].setdefault(
"403",
{
"content": {
"application/json": {
"schema": generic_error.ref,
}
},
"description": "",
},
)
method["responses"].setdefault("400", VALIDATION_ERROR_RESPONSE.ref)
method["responses"].setdefault("403", GENERIC_ERROR_RESPONSE.ref)
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
@@ -113,67 +68,18 @@ def postprocess_schema_responses(
return result
def postprocess_schema_pagination(
def postprocess_schema_query_params(
result: dict[str, Any], generator: SchemaGenerator, **kwargs
) -> dict[str, Any]:
"""Optimise pagination parameters, instead of redeclaring parameters for each endpoint
declare them globally and refer to them"""
to_replace = {
"ordering": create_component(
generator,
"QueryPaginationOrdering",
{
"name": "ordering",
"required": False,
"in": "query",
"description": "Which field to use when ordering the results.",
"schema": {"type": "string"},
},
ResolvedComponent.PARAMETER,
),
"page": create_component(
generator,
"QueryPaginationPage",
{
"name": "page",
"required": False,
"in": "query",
"description": "A page number within the paginated result set.",
"schema": {"type": "integer"},
},
ResolvedComponent.PARAMETER,
),
"page_size": create_component(
generator,
"QueryPaginationPageSize",
{
"name": "page_size",
"required": False,
"in": "query",
"description": "Number of results to return per page.",
"schema": {"type": "integer"},
},
ResolvedComponent.PARAMETER,
),
"search": create_component(
generator,
"QuerySearch",
{
"name": "search",
"required": False,
"in": "query",
"description": "A search term.",
"schema": {"type": "string"},
},
ResolvedComponent.PARAMETER,
),
}
LOGGER.debug("Deduplicating query parameters")
for path in result["paths"].values():
for method in path.values():
for idx, param in enumerate(method.get("parameters", [])):
for replace_name, replace_ref in to_replace.items():
if param["name"] == replace_name:
method["parameters"][idx] = replace_ref.ref
if param["name"] not in QUERY_PARAMS:
continue
method["parameters"][idx] = QUERY_PARAMS[param["name"]].ref
return result
@@ -185,9 +91,13 @@ def postprocess_schema_remove_unused(
# less efficient than walking through the tree but a lot simpler and no
# possibility that we miss something
raw = OpenApiJsonRenderer().render(result, renderer_context={}).decode()
count = 0
for key in result["components"][ResolvedComponent.SCHEMA].keys():
if raw.count(key) > 1:
schema_usages = raw.count(f"#/components/{ResolvedComponent.SCHEMA}/{key}")
if schema_usages >= 1:
continue
del generator.registry._components[(key, ResolvedComponent.SCHEMA)]
del generator.registry[(key, ResolvedComponent.SCHEMA)]
count += 1
LOGGER.debug("Removing unused components", count=count)
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
return result
View File
+65
View File
@@ -0,0 +1,65 @@
from django.utils.translation import gettext_lazy as _
from drf_spectacular.plumbing import (
ResolvedComponent,
build_basic_type,
build_parameter_type,
)
from drf_spectacular.types import OpenApiTypes
QUERY_PARAMS = {
"ordering": ResolvedComponent(
name="QueryPaginationOrdering",
type=ResolvedComponent.PARAMETER,
object="QueryPaginationOrdering",
schema=build_parameter_type(
name="ordering",
schema=build_basic_type(OpenApiTypes.STR),
location="query",
description=_("Which field to use when ordering the results."),
),
),
"page": ResolvedComponent(
name="QueryPaginationPage",
type=ResolvedComponent.PARAMETER,
object="QueryPaginationPage",
schema=build_parameter_type(
name="page",
schema=build_basic_type(OpenApiTypes.INT),
location="query",
description=_("A page number within the paginated result set."),
),
),
"page_size": ResolvedComponent(
name="QueryPaginationPageSize",
type=ResolvedComponent.PARAMETER,
object="QueryPaginationPageSize",
schema=build_parameter_type(
name="page_size",
schema=build_basic_type(OpenApiTypes.INT),
location="query",
description=_("Number of results to return per page."),
),
),
"search": ResolvedComponent(
name="QuerySearch",
type=ResolvedComponent.PARAMETER,
object="QuerySearch",
schema=build_parameter_type(
name="search",
schema=build_basic_type(OpenApiTypes.STR),
location="query",
description=_("A search term."),
),
),
# Not related to pagination but a very common query param
"name": ResolvedComponent(
name="QueryName",
type=ResolvedComponent.PARAMETER,
object="QueryName",
schema=build_parameter_type(
name="name",
schema=build_basic_type(OpenApiTypes.STR),
location="query",
),
),
}
+84
View File
@@ -0,0 +1,84 @@
from django.utils.translation import gettext_lazy as _
from drf_spectacular.plumbing import (
ResolvedComponent,
build_array_type,
build_basic_type,
build_object_type,
)
from drf_spectacular.types import OpenApiTypes
from rest_framework.settings import api_settings
GENERIC_ERROR = ResolvedComponent(
name="GenericError",
type=ResolvedComponent.SCHEMA,
object="GenericError",
schema=build_object_type(
description=_("Generic API Error"),
properties={
"detail": build_basic_type(OpenApiTypes.STR),
"code": build_basic_type(OpenApiTypes.STR),
},
required=["detail"],
),
)
GENERIC_ERROR_RESPONSE = ResolvedComponent(
name="GenericErrorResponse",
type=ResolvedComponent.RESPONSE,
object="GenericErrorResponse",
schema={
"content": {"application/json": {"schema": GENERIC_ERROR.ref}},
"description": "",
},
)
VALIDATION_ERROR = ResolvedComponent(
"ValidationError",
object="ValidationError",
type=ResolvedComponent.SCHEMA,
schema=build_object_type(
description=_("Validation Error"),
properties={
api_settings.NON_FIELD_ERRORS_KEY: build_array_type(build_basic_type(OpenApiTypes.STR)),
"code": build_basic_type(OpenApiTypes.STR),
},
required=[],
additionalProperties={},
),
)
VALIDATION_ERROR_RESPONSE = ResolvedComponent(
name="ValidationErrorResponse",
type=ResolvedComponent.RESPONSE,
object="ValidationErrorResponse",
schema={
"content": {
"application/json": {
"schema": VALIDATION_ERROR.ref,
}
},
"description": "",
},
)
PAGINATION = ResolvedComponent(
name="Pagination",
type=ResolvedComponent.SCHEMA,
object="Pagination",
schema=build_object_type(
properties={
"next": build_basic_type(OpenApiTypes.NUMBER),
"previous": build_basic_type(OpenApiTypes.NUMBER),
"count": build_basic_type(OpenApiTypes.NUMBER),
"current": build_basic_type(OpenApiTypes.NUMBER),
"total_pages": build_basic_type(OpenApiTypes.NUMBER),
"start_index": build_basic_type(OpenApiTypes.NUMBER),
"end_index": build_basic_type(OpenApiTypes.NUMBER),
},
required=[
"next",
"previous",
"count",
"current",
"total_pages",
"start_index",
"end_index",
],
),
)
+2 -4
View File
@@ -1,7 +1,7 @@
from rest_framework.response import Response
from authentik.api.pagination import Pagination
from authentik.enterprise.search.ql import AUTOCOMPLETE_COMPONENT_NAME, QLSearch
from authentik.enterprise.search.ql import AUTOCOMPLETE_SCHEMA, QLSearch
class AutocompletePagination(Pagination):
@@ -46,8 +46,6 @@ class AutocompletePagination(Pagination):
def get_paginated_response_schema(self, schema):
final_schema = super().get_paginated_response_schema(schema)
final_schema["properties"]["autocomplete"] = {
"$ref": f"#/components/schemas/{AUTOCOMPLETE_COMPONENT_NAME}"
}
final_schema["properties"]["autocomplete"] = AUTOCOMPLETE_SCHEMA.ref
final_schema["required"].append("autocomplete")
return final_schema
+7 -5
View File
@@ -6,6 +6,7 @@ from djangoql.ast import Name
from djangoql.exceptions import DjangoQLError
from djangoql.queryset import apply_search
from djangoql.schema import DjangoQLSchema
from drf_spectacular.plumbing import ResolvedComponent, build_object_type
from rest_framework.filters import SearchFilter
from rest_framework.request import Request
from structlog.stdlib import get_logger
@@ -13,11 +14,12 @@ from structlog.stdlib import get_logger
from authentik.enterprise.search.fields import JSONSearchField
LOGGER = get_logger()
AUTOCOMPLETE_COMPONENT_NAME = "Autocomplete"
AUTOCOMPLETE_SCHEMA = {
"type": "object",
"additionalProperties": {},
}
AUTOCOMPLETE_SCHEMA = ResolvedComponent(
name="Autocomplete",
object="Autocomplete",
type=ResolvedComponent.SCHEMA,
schema=build_object_type(additionalProperties={}),
)
class BaseSchema(DjangoQLSchema):
+2 -3
View File
@@ -1,9 +1,8 @@
from djangoql.serializers import DjangoQLSchemaSerializer
from drf_spectacular.generators import SchemaGenerator
from authentik.api.schema import create_component
from authentik.enterprise.search.fields import JSONSearchField
from authentik.enterprise.search.ql import AUTOCOMPLETE_COMPONENT_NAME, AUTOCOMPLETE_SCHEMA
from authentik.enterprise.search.ql import AUTOCOMPLETE_SCHEMA
class AKQLSchemaSerializer(DjangoQLSchemaSerializer):
@@ -24,6 +23,6 @@ class AKQLSchemaSerializer(DjangoQLSchemaSerializer):
def postprocess_schema_search_autocomplete(result, generator: SchemaGenerator, **kwargs):
create_component(generator, AUTOCOMPLETE_COMPONENT_NAME, AUTOCOMPLETE_SCHEMA)
generator.registry.register_on_missing(AUTOCOMPLETE_SCHEMA)
return result
+2 -1
View File
@@ -1,7 +1,8 @@
SPECTACULAR_SETTINGS = {
"POSTPROCESSING_HOOKS": [
"authentik.api.schema.postprocess_schema_register",
"authentik.api.schema.postprocess_schema_responses",
"authentik.api.schema.postprocess_schema_pagination",
"authentik.api.schema.postprocess_schema_query_params",
"authentik.api.schema.postprocess_schema_remove_unused",
"authentik.enterprise.search.schema.postprocess_schema_search_autocomplete",
"drf_spectacular.hooks.postprocess_schema_enums",
+2 -1
View File
@@ -187,8 +187,9 @@ SPECTACULAR_SETTINGS = {
"authentik.api.schema.preprocess_schema_exclude_non_api",
],
"POSTPROCESSING_HOOKS": [
"authentik.api.schema.postprocess_schema_register",
"authentik.api.schema.postprocess_schema_responses",
"authentik.api.schema.postprocess_schema_pagination",
"authentik.api.schema.postprocess_schema_query_params",
"authentik.api.schema.postprocess_schema_remove_unused",
"drf_spectacular.hooks.postprocess_schema_enums",
],
+4 -2
View File
@@ -47,7 +47,9 @@ class PromptStageViewSet(UsedByMixin, ModelViewSet):
class PromptSerializer(ModelSerializer):
"""Prompt Serializer"""
promptstage_set = StageSerializer(many=True, required=False)
prompt_stages_obj = PromptStageSerializer(
source="promptstage_set", many=True, required=False, read_only=True
)
class Meta:
model = Prompt
@@ -61,7 +63,7 @@ class PromptSerializer(ModelSerializer):
"placeholder",
"initial_value",
"order",
"promptstage_set",
"prompt_stages_obj",
"sub_text",
"placeholder_expression",
"initial_value_expression",
-99
View File
@@ -15259,105 +15259,6 @@
"maximum": 2147483647,
"title": "Order"
},
"promptstage_set": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"flow_set": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"slug": {
"type": "string",
"maxLength": 50,
"minLength": 1,
"pattern": "^[-a-zA-Z0-9_]+$",
"title": "Slug",
"description": "Visible in the URL."
},
"title": {
"type": "string",
"minLength": 1,
"title": "Title",
"description": "Shown as the Title in Flow pages."
},
"designation": {
"type": "string",
"enum": [
"authentication",
"authorization",
"invalidation",
"enrollment",
"unenrollment",
"recovery",
"stage_configuration"
],
"title": "Designation",
"description": "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik."
},
"policy_engine_mode": {
"type": "string",
"enum": [
"all",
"any"
],
"title": "Policy engine mode"
},
"compatibility_mode": {
"type": "boolean",
"title": "Compatibility mode",
"description": "Enable compatibility mode, increases compatibility with password managers on mobile devices."
},
"layout": {
"type": "string",
"enum": [
"stacked",
"content_left",
"content_right",
"sidebar_left",
"sidebar_right"
],
"title": "Layout"
},
"denied_action": {
"type": "string",
"enum": [
"message_continue",
"message",
"continue"
],
"title": "Denied action",
"description": "Configure what should happen when a flow denies access to a user."
}
},
"required": [
"name",
"slug",
"title",
"designation"
]
},
"title": "Flow set"
}
},
"required": [
"name"
]
},
"title": "Promptstage set"
},
"sub_text": {
"type": "string",
"title": "Sub text"
+2177 -10768
View File
File diff suppressed because it is too large Load Diff
@@ -75,7 +75,7 @@ export class PromptListPage extends TablePage<Prompt> {
html`<code>${item.fieldKey}</code>`,
html`${item.type}`,
html`${item.order}`,
html`${item.promptstageSet?.map((stage) => {
html`${item.promptStagesObj.map((stage) => {
return html`<li>${stage.name}</li>`;
})}`,
html`<ak-forms-modal size=${PFSize.XLarge}>