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;
}
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;
};
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"
]
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),
});
};
Top comments (0)