tfranzel / drf-spectacular

Sane and flexible OpenAPI 3 schema generation for Django REST framework.
https://drf-spectacular.readthedocs.io
BSD 3-Clause "New" or "Revised" License
2.37k stars 262 forks source link

Example for list response is either a list containing only the first element or a list inside a list in redoc, and is garbled in swagger. #1310

Open TauPan opened 1 week ago

TauPan commented 1 week ago

Describe the bug When I specify a list as an example (in a raw schema) in redoc only the first item in the given list is displayed as the only item in the example. If I wrap the example list into a list, the example is displayed as a list of list.

In swagger I always get a single element list with garbled data.

To Reproduce Note that this endpoint returns a list of (subset of) icinga (nagios) check items which are converted into the format expected by icinga checks. The data is provided by code that runs these checks and returns the appropriate status code and description.

So this is my application code (I elided some earlier definitions, but you get the idea, I think):

STATUS_INFO_NAME_SCHEMA = dict(
    type='string',
    pattern=r'sis\.[a-z_]+',
    description='Name des Checks (beginnt immer mit `"sis."`)'
)

STATUS_INFO_VALUE_SCHEMA = dict(
    type='string',
    enum=('ok', 'warning', 'error', 'critical'),
    description='Wert oder Schwere ("Severity") des Status.'
)

STATUS_INFO_TYPE_SCHEMA = dict(
    type='string',
    enum=('direct',),
    description='Nagios/Icinga Typ (immer `"direct"`)'
)

STATUS_INFO_DESCRIPTION_SCHEMA = dict(
    type='string',
    description='Menschenlesbare Beschreibung des Resultats'
)

INFO_NAMES = [
    'info',
    'configuration',
    'database',
    'fingerprints',
    'mail_host',
    'django_compatibility',
    'request_stats'
]

INFO_NAME_SCHEMA = dict(
    type='string',
    enum=tuple(
        f'sis.{name}'
        for name
        in INFO_NAMES),
    description='Name des Checks (beginnt immer mit `"sis."`)'
)

INFO_NAGIOS_SCHEMA = dict(
    type='string',
    pattern=(
        f'({" | ".join(INFO_NAME_SCHEMA["enum"])})'
        f' ({" | ".join(STATUS_INFO_TYPE_SCHEMA["enum"])})'
        f' ({" | ".join(STATUS_INFO_VALUE_SCHEMA["enum"])})'
        r' - .+'),
    description='Vollständige Ausgabe des Nagios/Icinga-Checks'
)

INFO_EXAMPLE = [
    OrderedDict([
        ("name", "sis.info"),
        ("value", "ok"),
        ("type", "direct"),
        ("description", "SIS version devel"),
        ("nagios", "sis.info direct ok - SIS version devel")]
    ),
    OrderedDict([
        ("name", "sis.configuration"),
        ("value", "critical"),
        ("type", "direct"),
        ("description", "Config option 'errors-to' missing"),
        ("nagios", "sis.configuration direct critical - Config option 'errors-to' missing")]  # noqa
    ),
    OrderedDict([
        ("name", "sis.database"),
        ("value", "ok"),
        ("type", "direct"),
        ("description", "Database readable"),
        ("nagios", "sis.database direct ok - Database readable")]
    ),
    OrderedDict([
        ("name", "sis.fingerprints"),
        ("value", "ok"),
        ("type", "direct"),
        ("description", "No duplicate fingerprints"),
        ("nagios", "sis.fingerprints direct ok - No duplicate fingerprints")]
    ),
    OrderedDict([
        ("name", "sis.mail_host"),
        ("value", "ok"),
        ("type", "direct"),
        ("description", "SMTP server reachable"),
        ("nagios", "sis.mail_host direct ok - SMTP server reachable")]
    ),
    OrderedDict([
        ("name", "sis.django_compatibility"),
        ("value", "ok"),
        ("type", "direct"),
        ("description", "SIS is compatible with the current version of Django"),  # noqa
        ("nagios", "sis.django_compatibility direct ok - SIS is compatible with the current version of Django")]  # noqa
    ),
    OrderedDict([
        ("name", "sis.request_stats"),
        ("value", "ok"),
        ("type", "direct"),
        ("description", "No request durations over 100 seconds in the last 5 days"),  # noqa
        ("nagios", "sis.request_stats direct ok - No request durations over 100 seconds in the last 5 days")]  # noqa
    )
]

INFO_ITEM_SCHEMA = dict(
    type='object',
    properties=OrderedDict({
        'name': INFO_NAME_SCHEMA,
        'value': STATUS_INFO_VALUE_SCHEMA,
        'type': STATUS_INFO_TYPE_SCHEMA,
        'description': STATUS_INFO_DESCRIPTION_SCHEMA,
        'nagios': INFO_NAGIOS_SCHEMA,
    }),
    examples=[INFO_EXAMPLE])

class InfoViewSet(viewsets.ViewSet):
    """[Allgemeine Info über den SIS-Server](/doc/#tag/info)

    Erlaubt zu Debugging-Zwecken eine Untermenge der
    Statusinformationen ohne Autorisierung.

    """
    resource = "info"
    permission_classes = [permissions.IsAuthenticated]

    @extend_schema(
        responses={200: INFO_ITEM_SCHEMA})
    def list(self, request):
        """List SIS Information

        [/api/info/](/api/info/)

        Liefert einen Status von einigen Selbsttest aus.
        """
        return Response([c.as_dict() for c in check.info()])

In this form, the example data will be given in redoc as a list of list.

[
  [
    {
      "name": "sis.info",
      "value": "ok",
      "type": "direct",
      "description": "SIS version devel",
      "nagios": "sis.info direct ok - SIS version devel"
    },
    {
      "name": "sis.configuration",
      "value": "critical",
      "type": "direct",
      "description": "Config option 'errors-to' missing",
      "nagios": "sis.configuration direct critical - Config option 'errors-to' missing"
    },
    {
      "name": "sis.database",
      "value": "ok",
      "type": "direct",
      "description": "Database readable",
      "nagios": "sis.database direct ok - Database readable"
    },
    {
      "name": "sis.fingerprints",
      "value": "ok",
      "type": "direct",
      "description": "No duplicate fingerprints",
      "nagios": "sis.fingerprints direct ok - No duplicate fingerprints"
    },
    {
      "name": "sis.mail_host",
      "value": "ok",
      "type": "direct",
      "description": "SMTP server reachable",
      "nagios": "sis.mail_host direct ok - SMTP server reachable"
    },
    {
      "name": "sis.django_compatibility",
      "value": "ok",
      "type": "direct",
      "description": "SIS is compatible with the current version of Django",
      "nagios": "sis.django_compatibility direct ok - SIS is compatible with the current version of Django"
    },
    {
      "name": "sis.request_stats",
      "value": "ok",
      "type": "direct",
      "description": "No request durations over 100 seconds in the last 5 days",
      "nagios": "sis.request_stats direct ok - No request durations over 100 seconds in the last 5 days"
    }
  ]
]

In swagger, the example looks garbled and only has the first item:

[
  {
    "name": "sis.info",
    "value": "ok",
    "type": "direct",
    "description": "string",
    "nagios": " sisVrequest_stats direct  critical - I{P{2prRCQe68zEfoE,tD"
  }
]

If I change the raw schema to

INFO_ITEM_SCHEMA = dict(
    type='object',
    properties=OrderedDict({
        'name': INFO_NAME_SCHEMA,
        'value': STATUS_INFO_VALUE_SCHEMA,
        'type': STATUS_INFO_TYPE_SCHEMA,
        'description': STATUS_INFO_DESCRIPTION_SCHEMA,
        'nagios': INFO_NAGIOS_SCHEMA,
    }),
    examples=INFO_EXAMPLE)

The example in redoc changes to:

[
  {
    "name": "sis.info",
    "value": "ok",
    "type": "direct",
    "description": "SIS version devel",
    "nagios": "sis.info direct ok - SIS version devel"
  }
]

i.e. just the first item. And swagger displays:

[
  {
    "name": "sis.info",
    "value": "ok",
    "type": "direct",
    "description": "string",
    "nagios": " sisdfingerprints  direct ok  -  >[U7/ Nw?D+jZQ%=og65=v^v>!Z+^!r)(WY/O46iRA0_4=H"
  }
]

Expected behavior I expect to be able to give a full list as an example to a list response and see exactly that list as an example.

I've seen https://drf-spectacular.readthedocs.io/en/stable/faq.html#my-viewset-list-does-not-return-a-list-but-a-single-object as well as https://github.com/tfranzel/drf-spectacular/issues/990 and I've tried to come up with a way to apply the hints in https://drf-spectacular.readthedocs.io/en/stable/faq.html#how-do-i-wrap-my-responses-my-endpoints-are-wrapped-in-a-generic-envelope to an on-the-fly serializer via https://drf-spectacular.readthedocs.io/en/stable/drf_spectacular.html#drf_spectacular.utils.inline_serializer but it seems easier to write a serializer for the data returned by check.info().

That seems wasteful since it already does return an array and I feel it should not be that hard.

Also why does something (redoc?) apparently wrap the first element of a list into a list if it's not a list AND keep a nested list as is? That garbled swagger output is also very weird.

TauPan commented 1 week ago

That garbled swagger output is also very weird.

Looking at the output, it seems as if swagger is trying to construct examples from the string format + regex.

Still that example is wrong since the full list of names (all of INFO_NAMES prefixed by sis.) would always be present in the response.

TauPan commented 1 week ago

Even if I correct the schema to be an array with prefixItems, each with its own example, redoc still displays it as a list of list.

I swagger-ui the example becomes null.

(This is the changed code. It's somewhat convoluted since I didn't want to rewrite the whole schema and examples and I wouldn't keep it that way. Just to illustrate:

INFO_ARRAY_SCHEMA = dict(
    type='array',
    prefixItems=[
        dict(
            type='object',
            properties=OrderedDict({
                'name': dict(
                    type='string',
                    enum=(f'sis.{n}',),
                    description='Name des Checks (beginnt immer mit `"sis."`)'
                ),
                'value': STATUS_INFO_VALUE_SCHEMA,
                'type': STATUS_INFO_TYPE_SCHEMA,
                'description': STATUS_INFO_DESCRIPTION_SCHEMA,
                'nagios': dict(
                    type='string',
                    pattern=(
                        f'({n})'
                        f' ({" | ".join(STATUS_INFO_TYPE_SCHEMA["enum"])})'
                        f' ({" | ".join(STATUS_INFO_VALUE_SCHEMA["enum"])})'
                        r' - .+'),
                    description='Vollständige Ausgabe des Nagios/Icinga-Checks'
                )
            }),
            example=[e for e in INFO_EXAMPLE if e['name'] == f'sis.{n}'][0]
        ) for n in INFO_NAMES
    ]
)

class InfoViewSet(viewsets.ViewSet):
    """[Allgemeine Info über den SIS-Server](/doc/#tag/info)

    Erlaubt zu Debugging-Zwecken eine Untermenge der
    Statusinformationen ohne Autorisierung.

    """
    resource = "info"
    permission_classes = [permissions.IsAuthenticated]

    @extend_schema(
        responses={200: INFO_ARRAY_SCHEMA})
    def list(self, request):
        """List SIS Information

        [/api/info/](/api/info/)

        Liefert einen Status von einigen Selbsttest aus.
        """
        return Response([c.as_dict() for c in check.info()])

The result in INFO_ARRAY_SCHEMA dumped to json is:

{
  "type": "array",
  "prefixItems": [
    {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "enum": [
            "sis.info"
          ],
          "description": "Name des Checks (beginnt immer mit `\"sis.\"`)"
        },
        "value": {
          "type": "string",
          "enum": [
            "ok",
            "warning",
            "error",
            "critical"
          ],
          "description": "Wert oder Schwere (\"Severity\") des Status."
        },
        "type": {
          "type": "string",
          "enum": [
            "direct"
          ],
          "description": "Nagios/Icinga Typ (immer `\"direct\"`)"
        },
        "description": {
          "type": "string",
          "description": "Menschenlesbare Beschreibung des Resultats"
        },
        "nagios": {
          "type": "string",
          "pattern": "(info) (direct) (ok | warning | error | critical) - .+",
          "description": "Vollst\u00e4ndige Ausgabe des Nagios/Icinga-Checks"
        }
      },
      "example": {
        "name": "sis.info",
        "value": "ok",
        "type": "direct",
        "description": "SIS version devel",
        "nagios": "sis.info direct ok - SIS version devel"
      }
    },
    {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "enum": [
            "sis.configuration"
          ],
          "description": "Name des Checks (beginnt immer mit `\"sis.\"`)"
        },
        "value": {
          "type": "string",
          "enum": [
            "ok",
            "warning",
            "error",
            "critical"
          ],
          "description": "Wert oder Schwere (\"Severity\") des Status."
        },
        "type": {
          "type": "string",
          "enum": [
            "direct"
          ],
          "description": "Nagios/Icinga Typ (immer `\"direct\"`)"
        },
        "description": {
          "type": "string",
          "description": "Menschenlesbare Beschreibung des Resultats"
        },
        "nagios": {
          "type": "string",
          "pattern": "(configuration) (direct) (ok | warning | error | critical) - .+",
          "description": "Vollst\u00e4ndige Ausgabe des Nagios/Icinga-Checks"
        }
      },
      "example": {
        "name": "sis.configuration",
        "value": "critical",
        "type": "direct",
        "description": "Config option 'errors-to' missing",
        "nagios": "sis.configuration direct critical - Config option 'errors-to' missing"
      }
    },
    {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "enum": [
            "sis.database"
          ],
          "description": "Name des Checks (beginnt immer mit `\"sis.\"`)"
        },
        "value": {
          "type": "string",
          "enum": [
            "ok",
            "warning",
            "error",
            "critical"
          ],
          "description": "Wert oder Schwere (\"Severity\") des Status."
        },
        "type": {
          "type": "string",
          "enum": [
            "direct"
          ],
          "description": "Nagios/Icinga Typ (immer `\"direct\"`)"
        },
        "description": {
          "type": "string",
          "description": "Menschenlesbare Beschreibung des Resultats"
        },
        "nagios": {
          "type": "string",
          "pattern": "(database) (direct) (ok | warning | error | critical) - .+",
          "description": "Vollst\u00e4ndige Ausgabe des Nagios/Icinga-Checks"
        }
      },
      "example": {
        "name": "sis.database",
        "value": "ok",
        "type": "direct",
        "description": "Database readable",
        "nagios": "sis.database direct ok - Database readable"
      }
    },
    {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "enum": [
            "sis.fingerprints"
          ],
          "description": "Name des Checks (beginnt immer mit `\"sis.\"`)"
        },
        "value": {
          "type": "string",
          "enum": [
            "ok",
            "warning",
            "error",
            "critical"
          ],
          "description": "Wert oder Schwere (\"Severity\") des Status."
        },
        "type": {
          "type": "string",
          "enum": [
            "direct"
          ],
          "description": "Nagios/Icinga Typ (immer `\"direct\"`)"
        },
        "description": {
          "type": "string",
          "description": "Menschenlesbare Beschreibung des Resultats"
        },
        "nagios": {
          "type": "string",
          "pattern": "(fingerprints) (direct) (ok | warning | error | critical) - .+",
          "description": "Vollst\u00e4ndige Ausgabe des Nagios/Icinga-Checks"
        }
      },
      "example": {
        "name": "sis.fingerprints",
        "value": "ok",
        "type": "direct",
        "description": "No duplicate fingerprints",
        "nagios": "sis.fingerprints direct ok - No duplicate fingerprints"
      }
    },
    {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "enum": [
            "sis.mail_host"
          ],
          "description": "Name des Checks (beginnt immer mit `\"sis.\"`)"
        },
        "value": {
          "type": "string",
          "enum": [
            "ok",
            "warning",
            "error",
            "critical"
          ],
          "description": "Wert oder Schwere (\"Severity\") des Status."
        },
        "type": {
          "type": "string",
          "enum": [
            "direct"
          ],
          "description": "Nagios/Icinga Typ (immer `\"direct\"`)"
        },
        "description": {
          "type": "string",
          "description": "Menschenlesbare Beschreibung des Resultats"
        },
        "nagios": {
          "type": "string",
          "pattern": "(mail_host) (direct) (ok | warning | error | critical) - .+",
          "description": "Vollst\u00e4ndige Ausgabe des Nagios/Icinga-Checks"
        }
      },
      "example": {
        "name": "sis.mail_host",
        "value": "ok",
        "type": "direct",
        "description": "SMTP server reachable",
        "nagios": "sis.mail_host direct ok - SMTP server reachable"
      }
    },
    {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "enum": [
            "sis.django_compatibility"
          ],
          "description": "Name des Checks (beginnt immer mit `\"sis.\"`)"
        },
        "value": {
          "type": "string",
          "enum": [
            "ok",
            "warning",
            "error",
            "critical"
          ],
          "description": "Wert oder Schwere (\"Severity\") des Status."
        },
        "type": {
          "type": "string",
          "enum": [
            "direct"
          ],
          "description": "Nagios/Icinga Typ (immer `\"direct\"`)"
        },
        "description": {
          "type": "string",
          "description": "Menschenlesbare Beschreibung des Resultats"
        },
        "nagios": {
          "type": "string",
          "pattern": "(django_compatibility) (direct) (ok | warning | error | critical) - .+",
          "description": "Vollst\u00e4ndige Ausgabe des Nagios/Icinga-Checks"
        }
      },
      "example": {
        "name": "sis.django_compatibility",
        "value": "ok",
        "type": "direct",
        "description": "SIS is compatible with the current version of Django",
        "nagios": "sis.django_compatibility direct ok - SIS is compatible with the current version of Django"
      }
    },
    {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "enum": [
            "sis.request_stats"
          ],
          "description": "Name des Checks (beginnt immer mit `\"sis.\"`)"
        },
        "value": {
          "type": "string",
          "enum": [
            "ok",
            "warning",
            "error",
            "critical"
          ],
          "description": "Wert oder Schwere (\"Severity\") des Status."
        },
        "type": {
          "type": "string",
          "enum": [
            "direct"
          ],
          "description": "Nagios/Icinga Typ (immer `\"direct\"`)"
        },
        "description": {
          "type": "string",
          "description": "Menschenlesbare Beschreibung des Resultats"
        },
        "nagios": {
          "type": "string",
          "pattern": "(request_stats) (direct) (ok | warning | error | critical) - .+",
          "description": "Vollst\u00e4ndige Ausgabe des Nagios/Icinga-Checks"
        }
      },
      "example": {
        "name": "sis.request_stats",
        "value": "ok",
        "type": "direct",
        "description": "No request durations over 100 seconds in the last 5 days",
        "nagios": "sis.request_stats direct ok - No request durations over 100 seconds in the last 5 days"
      }
    }
  ]
}

)

(The schema displays correctly in redoc, as well as in swagger, although without indentation and somewhat unhelpful in the latter and I will eventually go with that schema, but it doesn't help with the broken example.)