Repository: encode/django-rest-framework

Issue:
Unknown tag on redered schema when using Django's SafeString as help_text
### Discussed in https://github.com/encode/django-rest-framework/discussions/8402

Since:

- there's no response from the discussion thread,
- I think this is a valid issue, and
- I've found a possibly proper solution

I'll just put this as an issue and create a PR.

<div type='discussions-op-text'>

<sup>Originally posted by **hashlash** March 11, 2022</sup>
## The Issue

I have a model with SafeString as its help_text (inspired from [Django's password validation help text](https://github.com/django/django/blob/d90e34c61b27fba2527834806639eebbcfab9631/django/contrib/auth/password_validation.py#L84-L93)):

```py
from django.db import models
from django.utils.html import format_html, format_html_join


def list_help_text_html(help_texts):
    help_items = format_html_join(
        "", "<li>{}</li>", ((help_text,) for help_text in help_texts)
    )
    return format_html("<ul>{}</ul>", help_items) if help_items else ""


class ExampleModel(models.Model):
    field = models.TextField(
        help_text=list_help_text_html([
            'info a',
            'info b',
        ]),
    )
```

I followed the DRF's doc about [Generating a dynamic schema with SchemaView](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-dynamic-schema-with-schemaview) and [A minimal example with Swagger UI](https://www.django-rest-framework.org/topics/documenting-your-api/#a-minimal-example-with-swagger-ui), but the Swagger UI fails to render the generated schema.

```
Parser error on line 59
unknown tag !<tag:yaml.org,2002:python/object/new:django.utils.safestring.SafeString>
```

The generated schema ([full version](https://gist.github.com/hashlash/b7bc1b91b42625c6ac5ce7344299d8c7)):

```yaml
openapi: 3.0.2
info:
  # redacted
paths:
  # redacted
components:
  schemas:
    Example:
      type: object
      properties:
        id:
          # redacted
        field:
          type: string
          description: !!python/object/new:django.utils.safestring.SafeString
          - <ul><li>info a</li><li>info b</li></ul>
      required:
      - field
```

Here's [a repo](https://github.com/hashlash/django-demo/tree/drf/safestring-schema/bug) for demonstrating the bug. The Swagger UI can be accessed on `/swagger-ui/`.

Package versions:

```
django==3.2.12
djangorestframework==3.13.1
pyyaml==6.0
uritemplate==4.1.1
```

## Findings

I found that the schema generation for the help_text lies on:

https://github.com/encode/django-rest-framework/blob/a53e523f939332189b4ba8db7f99758b7d63e59b/rest_framework/schemas/openapi.py#L289-L290

and:

https://github.com/encode/django-rest-framework/blob/a53e523f939332189b4ba8db7f99758b7d63e59b/rest_framework/schemas/openapi.py#L537-L538

But since `SafeString` is a subclass of `str` and it override the `__str__()` method to return itself, those DRF's method will still return a `SafeString` object.

```py
class SafeString(str, SafeData):
    ...

    def __str__(self):
        return self
```

## Workaround

I've with 3 possible workarounds, and so far I think the third is the best:

- Adding the `SafeString` with an empty string ([implementation](https://github.com/hashlash/django-rest-framework/tree/str-add-fix), [demo](https://github.com/hashlash/django-demo/tree/drf/safestring-schema/str-add-fix))

  ```
  >>> from django.utils.safestring import SafeString
  >>> s = SafeString('abc')
  >>> s + ''
  'abc'
  >>> type(s + '')
  <class 'str'>
  ```

- Use `str.__str__()` ([implementation](https://github.com/hashlash/django-rest-framework/tree/str-str-fix), [demo](https://github.com/hashlash/django-demo/tree/drf/safestring-schema/str-str-fix))

  ```
  >>> from django.utils.safestring import SafeString
  >>> s = SafeString('abc')
  >>> str.__str__(s)
  'abc'
  >>> type(str.__str__(s))
  <class 'str'>
  ```

- Use `represent_str()` to represent `SafeString` as `str` ([implementation](https://github.com/hashlash/django-rest-framework/tree/yaml-representer-fix), [demo](https://github.com/hashlash/django-demo/tree/drf/safestring-schema/yaml-representation-fix))

  ```py
  class OpenAPIRenderer(BaseRenderer):
      ...
      def render(self, data, media_type=None, renderer_context=None):
          class Dumper(yaml.Dumper):
              ...
          Dumper.add_representer(SafeString, Dumper.represent_str)
          return yaml.dump(data, default_flow_style=False, sort_keys=False, Dumper=Dumper).encode('utf-8')
  ```

PS: I also asked about the "string conversion"  on [Django's forum](https://forum.djangoproject.com/t/convert-safestring-to-str/12549).</div>


Discussion:

Resolving patch:
diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py
--- a/rest_framework/renderers.py
+++ b/rest_framework/renderers.py
@@ -18,6 +18,7 @@
 from django.template import engines, loader
 from django.urls import NoReverseMatch
 from django.utils.html import mark_safe
+from django.utils.safestring import SafeString
 
 from rest_framework import VERSION, exceptions, serializers, status
 from rest_framework.compat import (
@@ -1060,6 +1061,7 @@ def render(self, data, media_type=None, renderer_context=None):
         class Dumper(yaml.Dumper):
             def ignore_aliases(self, data):
                 return True
+        Dumper.add_representer(SafeString, Dumper.represent_str)
         return yaml.dump(data, default_flow_style=False, sort_keys=False, Dumper=Dumper).encode('utf-8')
 
 


Test patch:
diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py
--- a/tests/schemas/test_openapi.py
+++ b/tests/schemas/test_openapi.py
@@ -5,6 +5,7 @@
 from django.db import models
 from django.test import RequestFactory, TestCase, override_settings
 from django.urls import path
+from django.utils.safestring import SafeString
 from django.utils.translation import gettext_lazy as _
 
 from rest_framework import filters, generics, pagination, routers, serializers
@@ -569,6 +570,11 @@ def test_openapi_yaml_rendering_without_aliases(self):
             renderer.render(data) == b'o2:\n  test: test\no1:\n  test: test\n'  # py <= 3.5
         )
 
+    def test_openapi_yaml_safestring_render(self):
+        renderer = OpenAPIRenderer()
+        data = {'o1': SafeString('test')}
+        assert renderer.render(data) == b'o1: test\n'
+
     def test_serializer_filefield(self):
         path = '/{id}/'
         method = 'POST'