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.41k stars 266 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 month ago

TauPan commented 1 month 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 month 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 month 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.)

TauPan commented 1 month ago

I've managed successfully create a serializer with many=False which results in the example listing the full set of info items, as intended, however this results in the schema being wrongly displayed as an object, instead of a list.

I've noticed that there are other usecases where I have a list serializer and only the first item in the example list is displayed and putting that list into a list causes a nested list.

I guess my point is that there are usecases where I want more than one item in an example list response.

The behaviour that the first item is used if the example list contains multiple object, but the full nested list is used if it's a nested list strikes me as inconsistent and is most certainly a bug.

(Here's the code that results in the correct example (full list of items) with wrong schema (object instead of list):

@extend_schema_field(INFO_NAME_SCHEMA)
class InfoNameField(CharField):
    pass

@extend_schema_field(STATUS_INFO_VALUE_SCHEMA)
class StatusInfoValueField(CharField):
    pass

@extend_schema_field(STATUS_INFO_TYPE_SCHEMA)
class StatusInfoTypeField(CharField):
    pass

@extend_schema_field(STATUS_INFO_DESCRIPTION_SCHEMA)
class StatusInfoDescriptionField(CharField):
    pass

@extend_schema_field(INFO_NAGIOS_SCHEMA)
class InfoNagiosField(CharField):
    pass

@extend_schema_serializer(many=False,
                          examples=[
                              OpenApiExample(
                                  'default',
                                  value=INFO_EXAMPLE
                              )])
class InfoListSerializer(Serializer):
    name = InfoNameField()
    value = StatusInfoValueField()
    type = StatusInfoTypeField()
    description = StatusInfoDescriptionField()
    nagios = InfoNagiosField()

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

    Liefert einen Status von einigen Selbsttest aus.

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

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

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

    @extend_schema(
        summary='List SIS Information',
        responses={200: InfoListSerializer})
    def list(self, request):
        return Response([c.as_dict() for c in check.info()])

)

TauPan commented 1 month ago

After some experimentation with modifying the schema directly with a post-processing hook it appears that the behaviour is caused by redoc and swagger, respectively. :roll_eyes:

Feel free to close if you agree with that assessment.

Otherwise I would appreciate a hint about how to achieve the desired behaviour with drf-spectacular.

Note that this did work with drf-yasg, but that bundled very different (much older) versions of redoc and swagger-ui.

TauPan commented 1 month ago

Uhm sorry for the noise, but it appears that this is not redoc's fault after all. If I upload the following example to https://redocly.github.io/redoc/:

openapi: 3.0.3
info:
  title: Example REST API
paths:
  /api/fixed_list/:
    get:
      operationId: fixed_list
      responses:
        200:
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                examples:
                  -  - name: info
                       value: ok
                       type: info
                       description: all ok
                     - name: database
                       value: critical
                       type: database
                       description: database not reachable

i.e. I attach the example to the array schema instead of the object and give a list containing a list as the only element, the example is rendered as I intend it.

However if I attach my INFO_EXAMPLE list to the list view directly like this:

    @extend_schema(
        summary='List SIS Information',
        responses={200: INFO_ITEM_SCHEMA},
    examples=[OpenApiExample('default', value=INFO_EXAMPLE)])
    def list(self, request):
        return Response([c.as_dict() for c in check.info()])

I still get a nested list as example and I have to use the following in a postprocessing hook to clean it up:

define sis_postprocessing_hook(result, generator, request, public):
    # [...] code that adds tag groups [...]
    # Attach examples to the `array` not to the `item`!
    # Workaround for https://github.com/tfranzel/drf-spectacular/issues/1310
    paths = result['paths']
    for path in ['/api/info/', '/api/status/']:
        content_200 = result['paths'][path]['get']['responses']['200']['content']
        for media_type in content_200.keys():
            example_value = content_200[media_type]['examples']['Default']['value']
            content_200[media_type]['examples']['Default']['value'] = example_value[0]
    return result

With this, examples are shown correctly in both redoc and swagger-ui as bundled in the current master of drf-spectacular-sidecar.

This looks a little more like a bug in drf-spectacular to me now, unless there's something I misunderstand.