DEV Community

Tom Bloom
Tom Bloom

Posted on • Edited on

A normalization function for json-api format

Recently I had to build a mobile application on top of an API respecting the json-api format.

Json-api is a great format for REST apis because it is normalized (follows a normalization pattern) and you can include all the data you want in the json response.

However, it is a bit messy on the client-side when you want to access the data. It is a bit too verbose and too deep, with "data", "type", "attributes", "relationships" and relationships have a similar structure recursively. So I wanted to re-normalize the data into a more javascript-friendly format.

In React it is better (and easier) to use the normalization pattern recommanded by the React or the Redux communities. react-query also talks about that.

So, the normalization function I came up with looks like this. First, let's define our entities in Typescript :

// File: entities.ts

export interface ApiResponse {
  data: Array<DataEntity>;
  included?: Array<DataEntity>;
}

export interface DataEntity {
  id: string;
  type: string;
  attributes: any;
  relationships?: { [key: string]: RelationshipData };
}

export interface NormalizedData {
  [entityType: string]: {
    [entityId: string]: DataEntity & RelationshipObject;
  };
}

export type RelationshipObject = {
  [key: string]: string | string[];
}

export interface RelationshipData {
  data: RelationshipItem | RelationshipItem[] | null;
}

export interface RelationshipItem {
  id: string;
  type: string;
}

Enter fullscreen mode Exit fullscreen mode

Then, let's write our normalization function. In Json-API, you always have the main data (in "data" key) and the included data (in the "included" data).

// File: normalization.ts

import { ApiResponse, NormalizedData, RelationshipData, DataEntity, RelationshipObject } from './entities';

const singularize = (word: string): string => {
  if (word.endsWith('ies')) {
      // e.g., "categories" to "category"
      return word.slice(0, -3) + 'y';
  } else if (word.endsWith('s')) {
      // e.g., "projects" to "project"
      return word.slice(0, -1);
  } else {
      // no change for words that do not end in 'ies' or 's'
      return word;
  }
}

const processRelationships = (relationships: { [key: string]: RelationshipData }) => {
    if (!relationships) {
        return {};
    }
    const result: RelationshipObject = {};
    for (const [key, relationship] of Object.entries(relationships)) {
        const relationshipData = relationship.data;
        if (Array.isArray(relationshipData)) {
            // Plural relationships
            const singularKey = singularize(key); // Convert to singular form
            result[`${singularKey}Ids`] = relationshipData.map(item => item.id);
        } else if (relationshipData) {
            // Singular relationships
            result[`${key}Id`] = relationshipData.id;
        }
    }
    return result;
};

export const normalizeData = (responseData: ApiResponse): NormalizedData => {
  const normalized: NormalizedData = {};

  const normalize = (item: DataEntity) => {
    const entityType = item.type + 's';
    normalized[entityType] = normalized[entityType] || {};
    normalized[entityType][item.id] = {
      ...item.attributes,
      id: item.id,
      ...processRelationships(item.relationships),
    };
  };

  responseData.data.forEach(normalize);
  responseData.included.forEach(normalize);

  return normalized;
};

Enter fullscreen mode Exit fullscreen mode

We use a singularize function to map the OneToMany relationships. E.g. if a Project has projectActivities, you want to transform the key to projectActivityIds and the value is an array of ids. If it is a ManyToOne relationship, you just have a single string for the Id.

Like this :

      "groupId": "178",
      "userId": "345",
      "projectActivityIds": [],
      "invoiceIds": [
        "24639",
        "24640",
        "25070",
        "25071",
        "84898"
      ]
Enter fullscreen mode Exit fullscreen mode

I had success with this format in my application so I recommand it. I added a test with jest to be sure that the json-api input gives exactly the right output.

But I actually should put it in an external package.

With react-query, I use it like this. Actually, it has nothing to have with react-query. I just use it in the basic fetch function that calls get() on the api object, a simple wrapper of the axios lib.

// File : queries.ts
import { useQuery } from '@tanstack/react-query';
import { api } from "./api";
import { normalizeData } from './normalization'

const fetchPhases = async (params) => {
  const { fromDate, toDate, userId } = params;
  const response = await api.get('/v5/plannings/phases', {
    params: {
      from: fromDate,
      to: toDate,
      users_id_in: [userId],
      include_invoices: true
    },
  });
  //console.log('response data', JSON.stringify(response.data, null, 2));
  return normalizeData(response.data);
}

export const usePhases = (params) => {
  return useQuery({
    queryKey: ['phases', params],
    queryFn: () => fetchPhases(params),
  });
};
Enter fullscreen mode Exit fullscreen mode

Top comments (0)