简体   繁体   中英

Is there an elegant way to flatten nested JSON with a django serializer?

I receive nested JSON from an API (I can't influence the structure). I want to flatten the nested fields while deserializing an object, using a django rest framework serializer . How do I do this elegantly?

Here is my current approach, which works by using nested serializers and doing the flattening in the .create() :

from dataclasses import dataclass

from rest_framework import serializers

input_data = {
    "objectName": "Johnny",
    "geoInfo": {
        "latitude": 1.2,
        "longitude": 3.4,
    },
}

flattened_output = {
    "name": "Johnny",
    "lat": 1.2,
    "lon": 3.4,
}


@dataclass
class Thing:
    name: str
    lat: float
    lon: float


class TheFlattener(serializers.Serializer):
    class GeoInfoSerializer(serializers.Serializer):
        latitude = serializers.FloatField()
        longitude = serializers.FloatField()

    objectName = serializers.CharField(max_length=50, source="name")
    geoInfo = GeoInfoSerializer()

    def create(self, validated_data):
        geo_info = validated_data.pop("geoInfo")
        validated_data["lat"] = geo_info["latitude"]
        validated_data["lon"] = geo_info["longitude"]
        return Thing(**validated_data)


serializer = TheFlattener(data=input_data)
serializer.is_valid(raise_exception=True)
assert serializer.save() == Thing(**flattened_output)

I know that when serializing objects to JSON, you can reference nested/related objects in the source parameter eg

first_name = CharField(source="user.first_name")

which is really nice, but I haven't been able to find something similar for deserialization.

for your situation I think your solution is as elegant as it gets, since you want to change the name of fields, not only flatten it.

However for larger nested Json you could define a flattening function (recursively/iteratively), or use some useful tool from pandas library such as:

pandas.json_normalize & pandas.DataFrame. to_dict/to_json

https://pandas.pydata.org/pandas-docs/version/1.2.0/reference/api/pandas.json_normalize.html

https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_dict.html

Option1

from pandas import json_normalize

input_data = {
    "objectName": "Johnny",
    "geoInfo": {
        "latitude": 1.2,
        "longitude": 3.4,
    },
}

d = json_normalize(input_data).to_dict(into=OrderedDict)
print(type(d))
print(d)

Output 1:

<class 'collections.OrderedDict'>
OrderedDict([('objectName', OrderedDict([(0, 'Johnny')])), ('geoInfo.latitude', OrderedDict([(0, 1.2)])), ('geoInfo.longitude', OrderedDict([(0, 3.4)]))])

Option 2

d = json_normalize(input_data).to_dict(into=OrderedDict, 'list')

Output 2

<class 'collections.OrderedDict'>
OrderedDict([('objectName', ['Johnny']), ('geoInfo.latitude', [1.2]), ('geoInfo.longitude', [3.4])])

Option 3

d = json_normalize(input_data).to_dict(into=OrderedDict, orient='records')

Output 3

<class 'list'>
[OrderedDict([('objectName', 'Johnny'), ('geoInfo.latitude', 1.2), ('geoInfo.longitude', 3.4)])]

Option 4

d = json_normalize(input_data).to_dict('index', into=OrderedDict)

Output 4

<class 'collections.OrderedDict'>
OrderedDict([(0, {'objectName': 'Johnny', 'geoInfo.latitude': 1.2, 'geoInfo.longitude': 3.4})])

For this case option 3 would be the best. But since it returns a list of length 1, add [0] .

Solution:

d = json_normalize(input_data).to_dict(orient='records')[0]

Output:

<class 'dict'>
{'objectName': 'Johnny', 'geoInfo.latitude': 1.2, 'geoInfo.longitude': 3.4}

For renaming fields a little more extra steps are required.

from pandas import json_normalize
from collections import OrderedDict

input_data = {
    "objectName": "Johnny",
    "geoInfo": {
        "latitude": 1.2,
        "longitude": 3.4,
    },
}

d = json_normalize(input_data).to_dict(orient='records', into=OrderedDict)[0]
N = len(d)
key_map = {"objectName": "name", "geoInfo.latitude": "lat", "geoInfo.longitude":"lon"}
# or use list ["name", "lat", "lon"] and access by index.
# I used dict key_map for cases when normal dict is used, instead of ordereddict
# (when into=OrderedDict argument is not given to pandas to_dict function)
for _ in range(N):
    k, v = d.popitem(last=False)
d[key_map[k]] = v

print(d)

Output:

OrderedDict([('name', 'Johnny'), ('lat', 1.2), ('lon', 3.4)])

I would suggest using recursing, something like that:

def make_it_flat(inp):
    out = dict()
    
    def flatten(piece, name=''):
        if isinstance(piece, dict):
            for k,v in piece.items():
                flatten(v, f'{name}{k}_')
        elif isinstance(piece, list):
            for i, a in enumerate(piece):
                flatten(a, f'{name}{i}_')
        else:
            out[name[:-1]] = piece
    flatten(inp)
    return out

Example:

>>> make_it_flat({22222: 'y', 1: {2: {3: ['a','b','c']}}})
{'22222': 'y', '1_2_3_0': 'a', '1_2_3_1': 'b', '1_2_3_2': 'c'}

Credit to this thread for providing the answer: source="*" flattens nested fields into the validated_data .

In this case, the solution looks like this:

class TheFlattener(serializers.Serializer):
    class GeoInfoSerializer(serializers.Serializer):
        latitude = serializers.FloatField(source="lat")  # rename here
        longitude = serializers.FloatField(source="lon")

    objectName = serializers.CharField(max_length=50, source="name")
    geoInfo = GeoInfoSerializer(source="*")  # unpack nested fields here

    def create(self, validated_data):  
        return Thing(**validated_data)

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM