Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Response schema from a TypedDict? #1350

Open
vhtkrk opened this issue Dec 10, 2024 · 2 comments
Open

Response schema from a TypedDict? #1350

vhtkrk opened this issue Dec 10, 2024 · 2 comments

Comments

@vhtkrk
Copy link

vhtkrk commented Dec 10, 2024

Hello!

I'm in the process of augmenting my DRF api with drf-spectacular, and I've run into a problem - or actually two, but they are somewhat related.

I have a Thing model, and instances of it contain configuration for a Javascript blob that I can embed into a website:

class Thing(models.Model):
    tos_urls = models.JSONField(default=dict)
    open_as_modal = models.BooleanField(default=False)
    min_limit = models.IntegerField(default=0)
    max_limit = models.IntegerField(default=0)
    customizations = models.JSONField(default=dict)
    # etc...

Next I have a ThingLoader which is built on top of Django's TemplateView:

class ThingLoader(TemplateView):
    template_name = 'loader.js'
    content_type = 'text/javascript; charset=utf-8'

    def get_context_data(self, **kwargs):
        # ... some processing here leading to a config object:
        cfg = {
            "apiroot": "https://api.example.com/api/v1",
            "tos_urls": {
                "en": "https://example.com",
                "fi": "https://example.fi",
                "se": "https://example.se",
            },
            "open_as_modal": True,
            "min_limit": 0,
            "max_limit": 1000,
            "customizations": {}
        }
        return {
            **super().get_context_data(**kwargs),
            "config": json.dumps(cfg),
            "jsUrl": ThingVersion.objects.latest().url
        }

And my loader.js looks like this:

if(!THING_CONFIG) {
    var THING_CONFIG = {{ config | safe }};

    (function() {
        var head = document.getElementsByTagName('head')[0];
        var js = document.createElement('script');
        js.src = '{{ jsUrl }}';
        js.id = 'thing';
        head.appendChild(js);
    })();
}

The end result is that I can embed my thing in a website with <script src="URL-to-my-ThingLoader"></script>

First question: Is there a way to get this endpoint to show up in the generated schema? It's in my urlpatterns in the same way as APIViews and ViewSets are but it doesn't show up. I tried to add @extend_schema_view(get=extend_schema(responses={(200, 'text/javascript'): bytes})) to ThingLoader but it errors out with AttributeError: type object 'ThingLoader' has no attribute 'schema'.

Next up I also have a TypedDict that is shaped after Thing's config:

class LocalizedDict(TypedDict):
    en: OpenApiTypes.URI
    fi: OpenApiTypes.URI
    se: Optional[OpenApiTypes.URI]

class ThingConfig(TypedDict):
    apiroot: OpenApiTypes.URI
    tos_urls: LocalizedDict
    open_as_modal: bool
    min_limit: int
    max_limit: int
    customizations: dict[str, OpenApiTypes.ANY]

And a somewhat hacked together view that returns the config as plain JSON:

class ThingJson(ThingLoader, APIView):
    permission_classes = (AllowAny,)

    @extend_schema(summary="Get thing config as JSON")
    def get(self, request, **kwargs):
        return HttpResponse(self.get_context_data(**kwargs)["config"], content_type="application/json")

Since this also inherits from APIView it shows up in my schema, but the response body shows up as empty. So that brings us to my second question: I can't figure out if there is a way to use the TypedDict I already have to tell drf-spectacular about the response?

Now the obvious answer would be to refactor the JSON view to use a serializer or define an inline_serializer, but it feels like repeating information that I already have in the TypedDict - especially since in reality there are way more fields than in my example.

Thanks in advance.

@tfranzel
Copy link
Owner

Q1: TemplateViews are not DRF views, so no.

Q2: So this is a bit tricky. We have the tooling for TypedDict, but only as part of type hint for a SerializerMethodField. We don't allow it as an entrypoint.

Here is a working extensions that add support. TypedDicts are fickle so this won't get native support. I had to add a hack because TypedDicts look easy, but are complicated under the hood and for that reason, the normal extensions matcher will not find it.

Put this extension somewhere the interpreter sees it: https://drf-spectacular.readthedocs.io/en/latest/customization.html#step-5-extensions or just next to the TypedDict

from drf_spectacular.extensions import OpenApiSerializerExtension
from drf_spectacular.plumbing import _resolve_typeddict

class TypedDictFix(OpenApiSerializerExtension):
    target_class = "typing._TypedDictMeta"
    match_subclasses = True

    def get_name(self, auto_schema, direction):
      return self.target.__name__

    @classmethod
    def _matches(cls, target) -> bool:
        """
        super-ugly hack because
            issubclass(SomeTypedDict, typing._TypedDictMeta) == False
            isinstance(SomeTypedDict, typing._TypedDictMeta) == True
        even though SomeTypedDict LOOKS LIKE CLASS
        """
        super()._matches(target)
        return isinstance(target, typing._TypedDictMeta)

    def map_serializer(self, auto_schema: 'AutoSchema', direction):
        return _resolve_typeddict(self.target)

and then this will work:

    class XAPIView(APIView):
        @extend_schema(responses=LocalizedDict)
        def get(self, request):
            pass  # pragma: no cover

However note that OpenApiTypes.URI is a enum and not a valid type so you cant use that.

@vhtkrk
Copy link
Author

vhtkrk commented Dec 11, 2024

Thank you, that seems to do the trick! Would it be worth adding this to the extension blueprints in the documentation?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants