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:
parent
564b2874a9
commit
2a2e159a0d
|
@ -1,12 +1,17 @@
|
||||||
"""Generate JSON Schema for blueprints"""
|
"""Generate JSON Schema for blueprints"""
|
||||||
from json import dumps, loads
|
from json import dumps
|
||||||
from pathlib import Path
|
from typing import Any
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, no_translations
|
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 structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.blueprints.v1.importer import is_model_allowed
|
from authentik.blueprints.v1.importer import is_model_allowed
|
||||||
from authentik.blueprints.v1.meta.registry import registry
|
from authentik.blueprints.v1.meta.registry import registry
|
||||||
|
from authentik.lib.models import SerializerModel
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
@ -16,21 +21,135 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
schema: dict
|
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
|
@no_translations
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""Generate JSON Schema for blueprints"""
|
"""Generate JSON Schema for blueprints"""
|
||||||
path = Path(__file__).parent.joinpath("./schema_template.json")
|
self.build()
|
||||||
with open(path, "r", encoding="utf-8") as _template_file:
|
self.stdout.write(dumps(self.schema, indent=4, default=Command.json_default))
|
||||||
self.schema = loads(_template_file.read())
|
|
||||||
self.set_model_allowed()
|
|
||||||
self.stdout.write(dumps(self.schema, indent=4))
|
|
||||||
|
|
||||||
def set_model_allowed(self):
|
@staticmethod
|
||||||
"""Set model enum"""
|
def json_default(value: Any) -> Any:
|
||||||
model_names = []
|
"""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():
|
for model in registry.get_models():
|
||||||
|
if model._meta.abstract:
|
||||||
|
continue
|
||||||
if not is_model_allowed(model):
|
if not is_model_allowed(model):
|
||||||
continue
|
continue
|
||||||
model_names.append(f"{model._meta.app_label}.{model._meta.model_name}")
|
model_instance: Model = model()
|
||||||
model_names.sort()
|
if not isinstance(model_instance, SerializerModel):
|
||||||
self.schema["properties"]["entries"]["items"]["properties"]["model"]["enum"] = model_names
|
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
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -160,6 +160,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
||||||
"managed",
|
"managed",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
|
"managed": {"read_only": True},
|
||||||
"key_data": {"write_only": True},
|
"key_data": {"write_only": True},
|
||||||
"certificate_data": {"write_only": True},
|
"certificate_data": {"write_only": True},
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
|
@ -1272,6 +1272,30 @@ websocket-client = ">=0.32.0"
|
||||||
[package.extras]
|
[package.extras]
|
||||||
ssh = ["paramiko (>=2.4.3)"]
|
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]]
|
[[package]]
|
||||||
name = "drf-spectacular"
|
name = "drf-spectacular"
|
||||||
version = "0.26.2"
|
version = "0.26.2"
|
||||||
|
@ -4152,4 +4176,4 @@ files = [
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.11"
|
python-versions = "^3.11"
|
||||||
content-hash = "82fc267d6041997d1410a951033cdb9f6c57d91df7d48acaecdbab320daab58e"
|
content-hash = "da0f14183137ec5d4fcd7df877f1488860bc26f795f8aaa19c78655f77e3f409"
|
||||||
|
|
|
@ -179,6 +179,7 @@ bump2version = "*"
|
||||||
colorama = "*"
|
colorama = "*"
|
||||||
coverage = { extras = ["toml"], version = "*" }
|
coverage = { extras = ["toml"], version = "*" }
|
||||||
django-silk = "*"
|
django-silk = "*"
|
||||||
|
drf-jsonschema-serializer = "*"
|
||||||
importlib-metadata = "*"
|
importlib-metadata = "*"
|
||||||
pylint = "*"
|
pylint = "*"
|
||||||
pylint-django = "*"
|
pylint-django = "*"
|
||||||
|
|
20
schema.yml
20
schema.yml
|
@ -27912,6 +27912,7 @@ components:
|
||||||
readOnly: true
|
readOnly: true
|
||||||
managed:
|
managed:
|
||||||
type: string
|
type: string
|
||||||
|
readOnly: true
|
||||||
nullable: true
|
nullable: true
|
||||||
title: Managed by authentik
|
title: Managed by authentik
|
||||||
description: Objects which are managed by authentik. These objects are created
|
description: Objects which are managed by authentik. These objects are created
|
||||||
|
@ -27924,6 +27925,7 @@ components:
|
||||||
- certificate_download_url
|
- certificate_download_url
|
||||||
- fingerprint_sha1
|
- fingerprint_sha1
|
||||||
- fingerprint_sha256
|
- fingerprint_sha256
|
||||||
|
- managed
|
||||||
- name
|
- name
|
||||||
- pk
|
- pk
|
||||||
- private_key_available
|
- private_key_available
|
||||||
|
@ -27946,15 +27948,6 @@ components:
|
||||||
writeOnly: true
|
writeOnly: true
|
||||||
description: Optional Private Key. If this is set, you can use this keypair
|
description: Optional Private Key. If this is set, you can use this keypair
|
||||||
for encryption.
|
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:
|
required:
|
||||||
- certificate_data
|
- certificate_data
|
||||||
- name
|
- name
|
||||||
|
@ -35649,15 +35642,6 @@ components:
|
||||||
writeOnly: true
|
writeOnly: true
|
||||||
description: Optional Private Key. If this is set, you can use this keypair
|
description: Optional Private Key. If this is set, you can use this keypair
|
||||||
for encryption.
|
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:
|
PatchedConsentStageRequest:
|
||||||
type: object
|
type: object
|
||||||
description: ConsentStage Serializer
|
description: ConsentStage Serializer
|
||||||
|
|
|
@ -5,6 +5,7 @@ Blueprints are YAML files, which can use some additional tags to ease blueprint
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
|
||||||
# The version of this blueprint, currently 1
|
# The version of this blueprint, currently 1
|
||||||
version: 1
|
version: 1
|
||||||
# Optional block of metadata, name is required if metadata is set
|
# Optional block of metadata, name is required if metadata is set
|
||||||
|
|
Reference in New Issue