django-ninja icon indicating copy to clipboard operation
django-ninja copied to clipboard

Content Negotiation

Open MiltosD opened this issue 1 year ago • 7 comments

Is it possible to return a different response based on the "Accept" header or a query param e.g. ?format=json-ld

MiltosD avatar Oct 16 '24 08:10 MiltosD

@MiltosD you can create custom renderer

overwrite render method - get Accept header and based on it value serialize response differently

vitalik avatar Oct 16 '24 08:10 vitalik

The example shows that renderers are applied to the api level

api = NinjaAPI(renderer=MyRenderer())

However the case is in a @api.get() view where I check for the header and decide which renderer (better off, which Schema) to use within the view, for example, if the Accept header is "application/ld+json", then the view response is to be of type MySchemaLD, else MySchema.

MiltosD avatar Oct 16 '24 09:10 MiltosD

I ended up with this, not sure if it's a neat approach

@api.get("/person/{pk}/", response=Any)
def get_person(request, pk, format: str = None):
    person = get_object_or_404(Person, pk=pk)
    by_alias = False
    if format == "json-ld":
        schema: Type[Schema] = PersonSchemaLD
        by_alias = True
    else:
        schema: Type[Schema] = PersonSchema
    return schema.from_orm(person).model_dump(
        exclude_unset=True,
        exclude_none=True,
        exclude_defaults=True,
        by_alias=by_alias
    )

It seems to work with /person/1?format=json-ld

MiltosD avatar Oct 16 '24 10:10 MiltosD

hard to tell - need to see how exactly your json and json-ld are different

vitalik avatar Oct 16 '24 13:10 vitalik

Well, a json-ld output contains a @context object which is not present in plain json, plus information for the fields, that are relevant to rdf. For example a person might be

plain json

{
  "name": "John Doe",
  "country": "Germany"
}

json-ld

{
  "@context": {
    "ex": "http://example.com",
    "dct": "http://purl.org/dc/terms/"
  },
  "@type": "foaf:Person",
  "foaf:name": "John Doe",
  "ex:country": {
    "@type": "dct:Location",
    "@id": "http://publications.europa.eu/resource/authority/country/DEU"
  }
}

The transformation from country to ex:country is done by using serialization_alias and resolvers

MiltosD avatar Oct 17 '24 05:10 MiltosD

We have a need to do something similar - in our case default to an application/json response but allow clients to specify text/csv using the Accept header. We've got it working nicely using a custom renderer BUT it seems that media_type is fixed per renderer. If we set it to application/json it uses that for all requests, even if we over-ride it for text/csv responses. If we leave it out at class level and try to set it dynamically per request, it always returns Content-type: None. If we return an HttpResponse with that header set explicitly it gets over-written.

Is this a bug or a feature?

FWIW happy to work on this and submit a PR for a change if it's something you'd be interested in.

andy-isoc avatar Apr 22 '25 15:04 andy-isoc

@andy-isoc could you show your example of your renderer class ?

vitalik avatar Apr 22 '25 15:04 vitalik

I also wanted to create a view that would return multiple content types based on Accept header. First I created a set of renderers:

class NinjaTextRenderer(NinjaBaseRenderer):
    media_type = 'text/plain'

    def render(self, request: HttpRequest, data: RendererContext, *, response_status: int) -> str:
        ...

class NinjaHtmlRenderer(NinjaTextRenderer):
    media_type = 'text/html'

And a view as such

def my_view(request, ...):
    ...
    # Do stuff
    ...
    renderers = [NinjaTextRenderer(), NinjaHtmlRenderer()]
    try:
        renderer, media_type = select_renderer(request, renderers=renderers)
    except ValueError:
        return HTTPStatus.NOT_ACCEPTABLE, None

    response = HttpResponse(
        content=renderer.render(request, RendererContext(template, data.context), response_status=HTTPStatus.OK),
    )
    response.headers["Content-Type"] = "{}; charset={}".format(media_type, response.charset)  # type: ignore[index]
    return response

select_renderer is mostly a copy of rest_framework.DefaultContentNegotiation.select_renderer.

Here are links to the actual code in our app:

  • Renderers and select_renderer: https://gitlab.nic.cz/fred/django-secretary/-/blob/e74ced4f5d22c81e135fbe1ffd3afb9844f072ec/django_secretary/renderers.py#L167-289
  • A view: https://gitlab.nic.cz/fred/django-secretary/-/blob/e74ced4f5d22c81e135fbe1ffd3afb9844f072ec/django_secretary/api/templates.py#L84-105

ziima avatar Nov 11 '25 08:11 ziima