blueprints: improve schema generation by including model schema (#5503)

* blueprints: improve schema generation by including model schema

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

* unset required

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

* add deps

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-05-07 12:32:01 +02:00 committed by GitHub
parent 564b2874a9
commit 2a2e159a0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 8348 additions and 257 deletions

View file

@ -1,12 +1,17 @@
"""Generate JSON Schema for blueprints"""
from json import dumps, loads
from pathlib import Path
from json import dumps
from typing import Any
from django.core.management.base import BaseCommand, no_translations
from django.db.models import Model
from drf_jsonschema_serializer.convert import field_to_converter
from rest_framework.fields import Field, JSONField, UUIDField
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.blueprints.v1.meta.registry import registry
from authentik.lib.models import SerializerModel
LOGGER = get_logger()
@ -16,21 +21,135 @@ class Command(BaseCommand):
schema: dict
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.schema = {
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": "authentik Blueprint schema",
"required": ["version", "entries"],
"properties": {
"version": {
"$id": "#/properties/version",
"type": "integer",
"title": "Blueprint version",
"default": 1,
},
"metadata": {
"$id": "#/properties/metadata",
"type": "object",
"required": ["name"],
"properties": {"name": {"type": "string"}, "labels": {"type": "object"}},
},
"context": {
"$id": "#/properties/context",
"type": "object",
"additionalProperties": True,
},
"entries": {
"type": "array",
"items": {
"oneOf": [],
},
},
},
"$defs": {},
}
@no_translations
def handle(self, *args, **options):
"""Generate JSON Schema for blueprints"""
path = Path(__file__).parent.joinpath("./schema_template.json")
with open(path, "r", encoding="utf-8") as _template_file:
self.schema = loads(_template_file.read())
self.set_model_allowed()
self.stdout.write(dumps(self.schema, indent=4))
self.build()
self.stdout.write(dumps(self.schema, indent=4, default=Command.json_default))
def set_model_allowed(self):
"""Set model enum"""
model_names = []
@staticmethod
def json_default(value: Any) -> Any:
"""Helper that handles gettext_lazy strings that JSON doesn't handle"""
return str(value)
def build(self):
"""Build all models into the schema"""
for model in registry.get_models():
if model._meta.abstract:
continue
if not is_model_allowed(model):
continue
model_names.append(f"{model._meta.app_label}.{model._meta.model_name}")
model_names.sort()
self.schema["properties"]["entries"]["items"]["properties"]["model"]["enum"] = model_names
model_instance: Model = model()
if not isinstance(model_instance, SerializerModel):
continue
serializer = model_instance.serializer()
model_path = f"{model._meta.app_label}.{model._meta.model_name}"
self.schema["properties"]["entries"]["items"]["oneOf"].append(
self.template_entry(model_path, serializer)
)
def template_entry(self, model_path: str, serializer: Serializer) -> dict:
"""Template entry for a single model"""
model_schema = self.to_jsonschema(serializer)
model_schema["required"] = []
def_name = f"model_{model_path}"
def_path = f"#/$defs/{def_name}"
self.schema["$defs"][def_name] = model_schema
return {
"type": "object",
"required": ["model", "attrs"],
"properties": {
"model": {"const": model_path},
"id": {"type": "string"},
"state": {
"type": "string",
"enum": ["absent", "present", "created"],
"default": "present",
},
"conditions": {"type": "array", "items": {"type": "boolean"}},
"attrs": {"$ref": def_path},
"identifiers": {"$ref": def_path},
},
}
def field_to_jsonschema(self, field: Field) -> dict:
"""Convert a single field to json schema"""
if isinstance(field, Serializer):
result = self.to_jsonschema(field)
else:
try:
converter = field_to_converter[field]
result = converter.convert(field)
except KeyError:
if isinstance(field, JSONField):
result = {"type": "object", "additionalProperties": True}
elif isinstance(field, UUIDField):
result = {"type": "string", "format": "uuid"}
else:
raise
if field.label:
result["title"] = field.label
if field.help_text:
result["description"] = field.help_text
return self.clean_result(result)
def clean_result(self, result: dict) -> dict:
"""Remove enumNames from result, recursively"""
result.pop("enumNames", None)
for key, value in result.items():
if isinstance(value, dict):
result[key] = self.clean_result(value)
return result
def to_jsonschema(self, serializer: Serializer) -> dict:
"""Convert serializer to json schema"""
properties = {}
required = []
for name, field in serializer.fields.items():
if field.read_only:
continue
sub_schema = self.field_to_jsonschema(field)
if field.required:
required.append(name)
properties[name] = sub_schema
result = {"type": "object", "properties": properties}
if required:
result["required"] = required
return result

View file

@ -1,105 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "http://example.com/example.json",
"type": "object",
"title": "authentik Blueprint schema",
"default": {},
"required": [
"version",
"entries"
],
"properties": {
"version": {
"$id": "#/properties/version",
"type": "integer",
"title": "Blueprint version",
"default": 1
},
"metadata": {
"$id": "#/properties/metadata",
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
},
"labels": {
"type": "object"
}
}
},
"context": {
"$id": "#/properties/context",
"type": "object",
"additionalProperties": true
},
"entries": {
"type": "array",
"items": {
"$id": "#entry",
"type": "object",
"required": [
"model"
],
"properties": {
"model": {
"type": "string",
"enum": [
"placeholder"
]
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"attrs": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Commonly available field, may not exist on all models"
}
},
"default": {},
"additionalProperties": true
},
"identifiers": {
"type": "object",
"default": {},
"properties": {
"pk": {
"description": "Commonly available field, may not exist on all models",
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"format": "uuid"
}
]
}
},
"additionalProperties": true
}
}
}
}
}
}

View file

@ -160,6 +160,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
"managed",
]
extra_kwargs = {
"managed": {"read_only": True},
"key_data": {"write_only": True},
"certificate_data": {"write_only": True},
}

File diff suppressed because it is too large Load diff

28
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry and should not be changed by hand.
# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
[[package]]
name = "aiohttp"
@ -1272,6 +1272,30 @@ websocket-client = ">=0.32.0"
[package.extras]
ssh = ["paramiko (>=2.4.3)"]
[[package]]
name = "drf-jsonschema-serializer"
version = "1.0.0"
description = "JSON Schema support for Django REST Framework"
category = "dev"
optional = false
python-versions = "*"
files = [
{file = "drf-jsonschema-serializer-1.0.0.tar.gz", hash = "sha256:aa58d03deba5a936bc0b0dbca4b69ee902886b7a0be130797f1d5e741b92e42b"},
{file = "drf_jsonschema_serializer-1.0.0-py3-none-any.whl", hash = "sha256:06401c94f1a2610797a26c390b701504b90b6b44683932daccbc250ea2aad3b1"},
]
[package.dependencies]
django = ">=3.2"
djangorestframework = ">=3.13"
jsonschema = ">=4.0.0"
[package.extras]
all-format-validators = ["fqdn", "idna", "isoduration", "jsonpointer", "rfc3339-validator", "rfc3987", "uri-template", "webcolors"]
coverage = ["pytest-cov"]
docs = ["sphinx", "sphinx-rtd-theme"]
release = ["bump2version", "twine"]
tests = ["black", "django-stubs[compatible-mypy]", "djangorestframework-stubs[compatible-mypy]", "flake8", "fqdn", "idna", "isoduration", "isort", "jsonpointer", "mypy", "pytest", "pytest-django", "rfc3339-validator", "rfc3987", "tox", "types-jsonschema", "uri-template", "webcolors"]
[[package]]
name = "drf-spectacular"
version = "0.26.2"
@ -4152,4 +4176,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "82fc267d6041997d1410a951033cdb9f6c57d91df7d48acaecdbab320daab58e"
content-hash = "da0f14183137ec5d4fcd7df877f1488860bc26f795f8aaa19c78655f77e3f409"

View file

@ -179,6 +179,7 @@ bump2version = "*"
colorama = "*"
coverage = { extras = ["toml"], version = "*" }
django-silk = "*"
drf-jsonschema-serializer = "*"
importlib-metadata = "*"
pylint = "*"
pylint-django = "*"

View file

@ -27912,6 +27912,7 @@ components:
readOnly: true
managed:
type: string
readOnly: true
nullable: true
title: Managed by authentik
description: Objects which are managed by authentik. These objects are created
@ -27924,6 +27925,7 @@ components:
- certificate_download_url
- fingerprint_sha1
- fingerprint_sha256
- managed
- name
- pk
- private_key_available
@ -27946,15 +27948,6 @@ components:
writeOnly: true
description: Optional Private Key. If this is set, you can use this keypair
for encryption.
managed:
type: string
nullable: true
minLength: 1
title: Managed by authentik
description: Objects which are managed by authentik. These objects are created
and updated automatically. This is flag only indicates that an object
can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update.
required:
- certificate_data
- name
@ -35649,15 +35642,6 @@ components:
writeOnly: true
description: Optional Private Key. If this is set, you can use this keypair
for encryption.
managed:
type: string
nullable: true
minLength: 1
title: Managed by authentik
description: Objects which are managed by authentik. These objects are created
and updated automatically. This is flag only indicates that an object
can be overwritten by migrations. You can still modify the objects via
the API, but expect changes to be overwritten in a later update.
PatchedConsentStageRequest:
type: object
description: ConsentStage Serializer

View file

@ -5,6 +5,7 @@ Blueprints are YAML files, which can use some additional tags to ease blueprint
## Structure
```yaml
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
# The version of this blueprint, currently 1
version: 1
# Optional block of metadata, name is required if metadata is set