Skip to content

Instantly share code, notes, and snippets.

@hucancode
Last active January 18, 2024 03:57
Show Gist options
  • Star 27 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save hucancode/5b495aabf75fc3b940df3e5f94d5b927 to your computer and use it in GitHub Desktop.
Save hucancode/5b495aabf75fc3b940df3e5f94d5b927 to your computer and use it in GitHub Desktop.
Flatten Strapi 4's response JSON

Update 29/11/2022

There is a plugin on Strapi Marketplace that do this response transforming stuffs in a more configurable way. Checkout this if you are interested.

// src/middlewares/flatten-response.js
function flattenArray(obj) {
return obj.map(e => flatten(e));
}
function flattenData(obj) {
return flatten(obj.data);
}
function flattenAttrs(obj) {
let attrs = {};
for (var key in obj.attributes) {
attrs[key] = flatten(obj.attributes[key]);
}
return {
id: obj.id,
...attrs
};
}
function flatten(obj) {
if(Array.isArray(obj)) {
return flattenArray(obj);
}
if(obj && obj.data) {
return flattenData(obj);
}
if(obj && obj.attributes) {
return flattenAttrs(obj);
}
return obj;
}
async function respond(ctx, next) {
await next();
if (!ctx.url.startsWith('/api')) {
return;
}
console.log(`API request (${ctx.url}) detected, transforming response json...`);
ctx.response.body = {
data: flatten(ctx.response.body.data),
meta: ctx.response.body.meta
};
}
module.exports = () => respond;
// config/middlewares.js
module.exports = [
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::logger',
'strapi::query',
'strapi::body',
'global::flatten-response',
'strapi::favicon',
'strapi::public',
];
@YassineElbouchaibi
Copy link

YassineElbouchaibi commented Feb 4, 2022

Graphql Support [Partially tested!!!]

// src/middlewares/flatten-response.js

const fs = require("fs");

const normalize = (data) => {
  const isObject = (data) =>
    Object.prototype.toString.call(data) === "[object Object]";
  const isArray = (data) =>
    Object.prototype.toString.call(data) === "[object Array]";

  const flatten = (data) => {
    if (!data.attributes) return data;

    return {
      id: data.id,
      ...data.attributes,
    };
  };

  if (isArray(data)) {
    return data.map((item) => normalize(item));
  }

  if (isObject(data)) {
    if (isArray(data.data)) {
      data = [...data.data];
    } else if (isObject(data.data)) {
      data = flatten({ ...data.data });
    } else if (data.data === null) {
      data = null;
    } else {
      data = flatten(data);
    }

    for (const key in data) {
      data[key] = normalize(data[key]);
    }

    return data;
  }

  return data;
};

const fixTypeDefName = (name) => {
  name = name.replace("RelationResponseCollection", "s");
  name = name.replace("EntityResponseCollection", "s");
  name = name.replace("EntityResponse", "");
  name = name.replace("Entity", "");

  return name;
};

const fixTypeRefName = (typeDef) => {
  if (
    typeDef.name != null &&
    typeDef.name.endsWith("EntityResponseCollection")
  ) {
    typeDef.ofType = {
      kind: "NON_NULL",
      name: null,
      ofType: {
        kind: "OBJECT",
        name: typeDef.name.replace("EntityResponseCollection", ""),
        ofType: null,
      },
    };
    typeDef.kind = "LIST";
    typeDef.name = null;

    return typeDef;
  }

  if (typeDef.ofType != null) {
    typeDef.ofType = fixTypeRefName(typeDef.ofType);
  }

  if (typeDef.name != null) {
    typeDef.name = fixTypeDefName(typeDef.name);
  }

  return typeDef;
};

const fixTypeDef = (typeDef) => {
  const fixedType = {
    ...typeDef,
    name: fixTypeDefName(typeDef.name),
  };

  fixedType.fields = typeDef.fields.map((y) => ({
    ...y,
    type: {
      ...fixTypeRefName(y.type),
    },
  }));

  return fixedType;
};

const respond = async (ctx, next) => {
  await next();

  // REST API response
  if (ctx.url.startsWith("/api")) {
    console.log(
      `API request (${ctx.url}) detected, transforming response json...`
    );
    ctx.response.body = {
      ...ctx.response.body,
      data: normalize(ctx.response.body.data),
    };
    return;
  }

  // GraphQL Response for Apollo Codegen script
  if (
    ctx.url.startsWith("/graphql") &&
    ctx.request.headers.apollocodegen === "true"
  ) {
    const parsedBody = JSON.parse(ctx.response.body);
    parsedBody.data.__schema.types = parsedBody.data.__schema.types
      .filter((x) => !x.name.endsWith("Entity"))
      .filter((x) => !x.name.endsWith("EntityResponse"))
      .filter((x) => !x.name.endsWith("EntityResponseCollection"))
      .map((x) => {
        if (x.fields == null) return x;
        if (x.name == null) return x;

        if (x.name === "Query" || x.name === "Mutation") {
          return {
            ...x,
            fields: x.fields.map((y) => ({
              ...y,
              type: {
                ...fixTypeRefName(y.type),
              },
            })),
          };
        }

        return fixTypeDef(x);
      });

    // Uncomment to Debug: Dump parsedBody to a file
    // fs.writeFileSync("./schema.json", JSON.stringify(parsedBody, null, 2));

    ctx.response.body = parsedBody;
    return;
  }

  // GraphQL Response for Apollo Client
  if (
    ctx.url.startsWith("/graphql") &&
    ctx.request.headers.normalize === "true"
  ) {
    const parsedBody = JSON.parse(ctx.response.body);

    if (parsedBody.data.__schema !== undefined) {
      return;
    }

    console.log(
      `API request (${ctx.url}) detected, transforming response json...`
    );

    ctx.response.body = {
      ...parsedBody.data,
      data: normalize(parsedBody.data),
    };
    return;
  }
};

module.exports = () => respond;

Apollo Codegen command

yarn apollo codegen:generate --target=typescript --tagName=gql --includes='operations/**/types.ts' --endpoint=http://localhost:1337/graphql --header='apollocodegen: true'

Folder Structure for queries:
image

index.ts should contain the real query

import { gql } from '@apollo/client';

export const GET_ASSETS_QUERY = gql`
  query GetAssetsQuery {
    asset {
      data {
        attributes {
          banner {
            data {
              attributes {
                url
              }
            }
          }
          logo {
            data {
              attributes {
                url
              }
            }
          }
        }
      }
    }
  }
`;

export * from './__generated__/GetAssetsQuery';

types.ts the response type

import { gql } from '@apollo/client';

export const GET_ASSETS_QUERY = gql`
  query GetAssetsQuery {
    asset {
      banner {
        url
      }
      logo {
        url
      }
    }
  }
`;

Final result is this :
image

And the return types will be generated correctly in your typescript code

@hucancode
Copy link
Author

Hi, thanks for the code. Personally I think it's still counter intuitive. In the graphql code we still have to pick data/attributes, nesting is still scary.

@allanvobraun
Copy link

Unfortunately it doesn't seem to work with populate

API request (/api/technologies?populate=*) detected, transforming response json...
[2022-02-06 17:33:24.171] error: Cannot read property 'data' of null
TypeError: Cannot read property 'data' of null
    at flatten (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:24:11)
    at flattenAttrs (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:12:18)
    at flatten (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:28:12)
    at flattenData (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:6:10)
    at flatten (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:25:12)
    at flattenAttrs (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:12:18)
    at flatten (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:28:12)
    at /home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:2:25
    at Array.map (<anonymous>)
    at flattenArray (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:2:14)
    at flatten (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:22:12)
    at respond (/home/allanbraun/projetos/pernonal-site/apps/backend/src/middlewares/flatten-response.js:42:11)
    at async /home/allanbraun/projetos/pernonal-site/node_modules/@strapi/strapi/lib/middlewares/body.js:24:7
    at async /home/allanbraun/projetos/pernonal-site/node_modules/@strapi/strapi/lib/middlewares/logger.js:22:5
    at async /home/allanbraun/projetos/pernonal-site/node_modules/@strapi/strapi/lib/middlewares/powered-by.js:16:5
    at async cors (/home/allanbraun/projetos/pernonal-site/node_modules/@koa/cors/index.js:56:32)

@allanvobraun
Copy link

I fixed! Its a problem with null values, a simple if check do the trick

function flattenArray(obj) {
  return obj.map((e) => flatten(e));
}

function flattenData(obj) {
  return flatten(obj.data);
}

function flattenAttrs(obj) {
  let attrs = {};
  for (let key in obj.attributes) {
    attrs[key] = flatten(obj.attributes[key]);
  }
  return {
    id: obj.id,
    ...attrs,
  };
}

function flatten(obj) {
  if (obj === null || obj === undefined) {
    return obj;
  }

  if (Array.isArray(obj)) {
    return flattenArray(obj);
  }
  if (obj.data) {
    return flattenData(obj);
  }
  if (obj.attributes) {
    return flattenAttrs(obj);
  }
  return obj;
}

async function respond(ctx, next) {
  await next();
  if (!ctx.url.startsWith("/api")) {
    return;
  }
  console.log(
    `API request (${ctx.url}) detected, transforming response json...`
  );
  ctx.response.body = {
    data: flatten(ctx.response.body.data),
    meta: ctx.response.body.meta,
  };
}

module.exports = () => respond;

@hucancode
Copy link
Author

Thanks for the input, I updated mine.

@SoftCreatR
Copy link

SoftCreatR commented Feb 19, 2022

@YassineElbouchaibi Just tried your solution, however, the result is somewhat strange.

Query:

query Company {
  company {
    data {
      attributes {
        content
      }
    }
  }
}

Original result:

{
  "data": {
    "company": {
      "data": {
        "attributes": {
          "content": "foo"
        }
      }
    }
  }
}

Normalized:

{
  "company": {
    "data": {
      "attributes": {
        "content": "foo"
      }
    }
  },
  "data": {
    "company": {
      "content": "foo"
    }
  }
}

@yan-goncalves
Copy link

yan-goncalves commented Apr 2, 2022

@YassineElbouchaibi Just tried your solution, however, the result is somewhat strange.

Query:

query Company {
  company {
    data {
      attributes {
        content
      }
    }
  }
}

Original result:

{
  "data": {
    "company": {
      "data": {
        "attributes": {
          "content": "foo"
        }
      }
    }
  }
}

Normalized:

{
  "company": {
    "data": {
      "attributes": {
        "content": "foo"
      }
    }
  },
  "data": {
    "company": {
      "content": "foo"
    }
  }
}

Hey @SoftCreatR! I've just put data: [queryName] before the query name and got the expected result, see example below:

image

By the way, thanks a lot guys for that workaround @hucancode and @YassineElbouchaibi.

@yan-goncalves
Copy link

yan-goncalves commented Apr 2, 2022

Guys, I've changed response body placing ...parsedBody.data inside the data attr. Doing that I didn't need put data: [queryName] anymore on graphql playground. I didn't test enough, but so far has been working for me. Could you tell me if doing this might break at some point?

ctx.response.body = {
      // ...parsedBody.data,
      data: {
        ...parsedBody.data,
        ...normalize(parsedBody.data),
      },
};

image

@zimoo354
Copy link

I was facing some issues because this function only flattens the first depth level of the response. I made some tweaks and now it flattens all the depth levels. Specially useful if you work with relationships and using populate when calling the API:

const strapiFlatten = (data) => {
  const isObject = (data) => Object.prototype.toString.call(data) === '[object Object]';
  const isArray = (data) => Object.prototype.toString.call(data) === '[object Array]';

  const flatten = (data) => {
    if (!data.attributes) return data;

    return {
      id: data.id,
      ...data.attributes,
    };
  };

  if (isArray(data)) {
    return data.map((item) => strapiFlatten(item));
  }

  if (isObject(data)) {
    if (isArray(data.data)) {
      data = [...data.data];
    } else if (isObject(data.data)) {
      data = flatten({ ...data.data });
    } else if (data.data === null) {
      data = null;
    } else {
      data = flatten(data);
    }

    for (const key in data) {
      data[key] = strapiFlatten(data[key]);
    }

    return data;
  }

  return data;
};

async function respond(ctx, next) {
  await next();
  if (!ctx.url.startsWith("/api")) {
    return;
  }
  console.log(
    `API request (${ctx.url}) detected, transforming response json...`
  );
  ctx.response.body = {
    data: strapiFlatten(ctx.response.body.data),
    meta: ctx.response.body.meta,
  };
}

module.exports = () => respond;

@candidosales
Copy link

Thanks, @zimoo354 !!

@levidavidmurray
Copy link

I was getting an empty object when authenticating with Google auth provider. It was trying to return: { "jwt": "...", "user" {...} }. Not sure what the best approach would be for this, but I just added an additional check to short circuit the flattening if the response body doesn't have a data key.

const strapiFlatten = (data) => {
    const isObject = (data) => Object.prototype.toString.call(data) === '[object Object]';
    const isArray = (data) => Object.prototype.toString.call(data) === '[object Array]';

    const flatten = (data) => {
        if (!data.attributes) return data;

        return {
            id: data.id,
            ...data.attributes,
        };
    };

    if (isArray(data)) {
        return data.map((item) => strapiFlatten(item));
    }

    if (isObject(data)) {
        if (isArray(data.data)) {
            data = [...data.data];
        } else if (isObject(data.data)) {
            data = flatten({ ...data.data });
        } else if (data.data === null) {
            data = null;
        } else {
            data = flatten(data);
        }

        for (const key in data) {
            data[key] = strapiFlatten(data[key]);
        }

        return data;
    }

    return data;
};

async function respond(ctx, next) {
    await next();
    if (!ctx.url.startsWith("/api") || !ctx.response.body.data) {
        return;
    }

    console.log(
        `API request (${ctx.url}) detected, transforming response json...`
    );
    ctx.response.body = {
        data: strapiFlatten(ctx.response.body.data),
        meta: ctx.response.body.meta,
    };
}

module.exports = () => respond;

@kashipai
Copy link

kashipai commented Oct 17, 2022

Graphql Support [Partially tested!!!]

// src/middlewares/flatten-response.js

const fs = require("fs");

const normalize = (data) => {
  const isObject = (data) =>
    Object.prototype.toString.call(data) === "[object Object]";
  const isArray = (data) =>
    Object.prototype.toString.call(data) === "[object Array]";

  const flatten = (data) => {
    if (!data.attributes) return data;

    return {
      id: data.id,
      ...data.attributes,
    };
  };

  if (isArray(data)) {
    return data.map((item) => normalize(item));
  }

  if (isObject(data)) {
    if (isArray(data.data)) {
      data = [...data.data];
    } else if (isObject(data.data)) {
      data = flatten({ ...data.data });
    } else if (data.data === null) {
      data = null;
    } else {
      data = flatten(data);
    }

    for (const key in data) {
      data[key] = normalize(data[key]);
    }

    return data;
  }

  return data;
};

const fixTypeDefName = (name) => {
  name = name.replace("RelationResponseCollection", "s");
  name = name.replace("EntityResponseCollection", "s");
  name = name.replace("EntityResponse", "");
  name = name.replace("Entity", "");

  return name;
};

const fixTypeRefName = (typeDef) => {
  if (
    typeDef.name != null &&
    typeDef.name.endsWith("EntityResponseCollection")
  ) {
    typeDef.ofType = {
      kind: "NON_NULL",
      name: null,
      ofType: {
        kind: "OBJECT",
        name: typeDef.name.replace("EntityResponseCollection", ""),
        ofType: null,
      },
    };
    typeDef.kind = "LIST";
    typeDef.name = null;

    return typeDef;
  }

  if (typeDef.ofType != null) {
    typeDef.ofType = fixTypeRefName(typeDef.ofType);
  }

  if (typeDef.name != null) {
    typeDef.name = fixTypeDefName(typeDef.name);
  }

  return typeDef;
};

const fixTypeDef = (typeDef) => {
  const fixedType = {
    ...typeDef,
    name: fixTypeDefName(typeDef.name),
  };

  fixedType.fields = typeDef.fields.map((y) => ({
    ...y,
    type: {
      ...fixTypeRefName(y.type),
    },
  }));

  return fixedType;
};

const respond = async (ctx, next) => {
  await next();

  // REST API response
  if (ctx.url.startsWith("/api")) {
    console.log(
      `API request (${ctx.url}) detected, transforming response json...`
    );
    ctx.response.body = {
      ...ctx.response.body,
      data: normalize(ctx.response.body.data),
    };
    return;
  }

  // GraphQL Response for Apollo Codegen script
  if (
    ctx.url.startsWith("/graphql") &&
    ctx.request.headers.apollocodegen === "true"
  ) {
    const parsedBody = JSON.parse(ctx.response.body);
    parsedBody.data.__schema.types = parsedBody.data.__schema.types
      .filter((x) => !x.name.endsWith("Entity"))
      .filter((x) => !x.name.endsWith("EntityResponse"))
      .filter((x) => !x.name.endsWith("EntityResponseCollection"))
      .map((x) => {
        if (x.fields == null) return x;
        if (x.name == null) return x;

        if (x.name === "Query" || x.name === "Mutation") {
          return {
            ...x,
            fields: x.fields.map((y) => ({
              ...y,
              type: {
                ...fixTypeRefName(y.type),
              },
            })),
          };
        }

        return fixTypeDef(x);
      });

    // Uncomment to Debug: Dump parsedBody to a file
    // fs.writeFileSync("./schema.json", JSON.stringify(parsedBody, null, 2));

    ctx.response.body = parsedBody;
    return;
  }

  // GraphQL Response for Apollo Client
  if (
    ctx.url.startsWith("/graphql") &&
    ctx.request.headers.normalize === "true"
  ) {
    const parsedBody = JSON.parse(ctx.response.body);

    if (parsedBody.data.__schema !== undefined) {
      return;
    }

    console.log(
      `API request (${ctx.url}) detected, transforming response json...`
    );

    ctx.response.body = {
      ...parsedBody.data,
      data: normalize(parsedBody.data),
    };
    return;
  }
};

module.exports = () => respond;

Apollo Codegen command

yarn apollo codegen:generate --target=typescript --tagName=gql --includes='operations/**/types.ts' --endpoint=http://localhost:1337/graphql --header='apollocodegen: true'

Folder Structure for queries: image

index.ts should contain the real query

import { gql } from '@apollo/client';

export const GET_ASSETS_QUERY = gql`
  query GetAssetsQuery {
    asset {
      data {
        attributes {
          banner {
            data {
              attributes {
                url
              }
            }
          }
          logo {
            data {
              attributes {
                url
              }
            }
          }
        }
      }
    }
  }
`;

export * from './__generated__/GetAssetsQuery';

types.ts the response type

import { gql } from '@apollo/client';

export const GET_ASSETS_QUERY = gql`
  query GetAssetsQuery {
    asset {
      banner {
        url
      }
      logo {
        url
      }
    }
  }
`;

Final result is this : image

And the return types will be generated correctly in your typescript code

This works as expected. With some amount of changes to get Strapi admin to maintain its sanity

The code with version 4.4.3 of strapi covered in the gist

@kasongoyo
Copy link

A tiny improvement to forward error as they're without tampering with them

function flattenArray(obj) {
    return obj.map((e) => flatten(e));
}

function flattenData(obj) {
    return flatten(obj.data);
}

function flattenAttrs(obj) {
    let attrs = {};
    for (let key in obj.attributes) {
        attrs[key] = flatten(obj.attributes[key]);
    }
    return {
        id: obj.id,
        ...attrs,
    };
}

function flatten(obj) {
    if (obj === null || obj === undefined) {
        return obj;
    }

    if (Array.isArray(obj)) {
        return flattenArray(obj);
    }
    if (obj.data) {
        return flattenData(obj);
    }
    if (obj.attributes) {
        return flattenAttrs(obj);
    }
    return obj;
}

async function respond(ctx, next) {
    await next();
    if (!ctx.url.startsWith("/api")) {
        return;
    }
    console.log(
        `API request (${ctx.url}) detected, transforming response json...`
    );

    if (ctx.response.status < 400) {
        const body = {
            data: ctx.response.body.data && flatten(ctx.response.body.data),
            meta: ctx.response.body.meta
        }

        ctx.response.body = body
    }

}

module.exports = () => respond;

@fperreaultnv
Copy link

The flatten function can be improved like this :

function flatten(obj) {
    if (obj === null || obj === undefined) {
        return obj;
    }
    
    if (obj.data) {
        return flattenData(obj);
    }

    if (Array.isArray(obj)) {
        return flattenArray(obj);
    }
    
    if (obj.attributes) {
        return flattenAttrs(obj);
    }

    if (typeof obj !== 'string') {
        for (let key in obj) {
            obj[key] = flatten(obj[key]);
        }
    }

    return obj;
}

This ensures deeper flattening, especially when the model si composed of components and dynamiczone content types

@mauriciabad
Copy link

None of the scripts above work for GraphQL XD.

I started debugging it for a while and gave up because I was getting annoyed. But I'll share my learnings in case a masochist wants to fx this coursed script.

  1. The file should be placed in: /src/middlewares/flatten-response.ts. And the config file we're change in is /config/middlewares.ts. This wasn't obvious to me...

  2. The Playground is broken, a new if statement needs to be added to skip changing the GET request of the actual HTML:

    if (ctx.url.startsWith("/graphql") && ctx.request.method === 'GET' ) return
  3. When I was running my codegen script the condition ctx.request.body.operationName === "IntrospectionQuery" wasn't true. This one is:

    if (ctx.url.startsWith("/graphql") && ctx.request.body.query.startsWith("query IntrospectionQuery") ) {/* code */}
  4. The parsedBody was not properly cloned, you need to do something like this:

    const { data, ...restBody } = parsedBody
    ctx.response.body = {
      ...restBody,
      data: normalize(data),
    };

    This is the wrong code it fixes.:

    // This is wrong! Don't copy it!
    ctx.response.body = {
      ...parsedBody.data, // I don't understand what this was meant to do... seems useless...
      data: normalize(parsedBody.data),
    };
  5. After all this changes, I realized that the input still needs to use the "old" ugly structure with all the data and attributes. This totally makes no sense, so I quit here.

What I will do: Make a function in the frontend that converts the response to a pretty format, but the GraphQL query will remain ugly. And not waste any more time with middlewares.

@mauriciabad
Copy link

It's me again, this is the script I ended up doing. The code is not clean, but the exported methods let the code where it's used be clean. Could be improved because I did it in an afternoon for a new project and didn't use it in a large codebase to find edge cases, but for a basic usage works. Maybe I give updates about the new use cases I find.

Type inference works perfectly.

Basically, you place this file wherever you want in the frontend. Not in strapi backend, and call it after the GraphQL request to parse the results into a readable format while keeping the types.

// src/lib/gql/index.ts <-- You can chose another location

/* eslint-disable @typescript-eslint/no-explicit-any */

export function simplifyResponse<T extends ObjectType>(response: T): SimpleResponse<T> {
  const entries = Object.entries(response).filter(([k]) => k !== '__typename')
  if (entries.length >= 2) throw new Error('Cannot simplify a Strapi response that contains an object with more than one key')
  return simplify(entries[0][1] as any)
}

export function simplify<T extends ValidType>(value: T): SimpleType<T>
export function simplify<T>(value: T) {
  if (Array.isArray(value)) return value.map(simplify)

  if (isPlainObject(value)) {
    if ('data' in value) return simplify(value.data)
    if ('attributes' in value) return simplify(value.attributes)
    return objectMap(value, simplify)
  }

  return value
}

function isPlainObject<O extends R | any, R extends Record<string | number | symbol, any>>(obj: O): obj is R {
  return typeof obj === 'object' && obj !== null && obj.constructor === Object && Object.getPrototypeOf(obj) === Object.prototype;
}

interface Dictionary<T> {
  [key: string]: T;
}

function objectMap<TValue, TResult>(
  obj: Dictionary<TValue>,
  valSelector: (val: TValue, obj: Dictionary<TValue>) => TResult,
  keySelector?: (key: string, obj: Dictionary<TValue>) => string,
  ctx?: Dictionary<TValue>
) {
  const ret = {} as Dictionary<TResult>;
  for (const key of Object.keys(obj)) {
    if (key === '__typename') continue;
    const retKey = keySelector
      ? keySelector.call(ctx || null, key, obj)
      : key;
    const retVal = valSelector.call(ctx || null, obj[key], obj);
    ret[retKey] = retVal;
  }
  return ret;
}

type ValidType = UntouchedType | ObjectType | ArrayType

type UntouchedType = boolean | number | string | symbol | null | undefined | bigint | Date
type ObjectType = { [key in string]?: ValidType }
type ArrayType = ValidType[]

type IsAny<T> = unknown extends T & string ? true : false;

export type SimpleType<T extends ValidType> = IsAny<T> extends true ? any : (T extends UntouchedType ? T
  : T extends [...(infer Ar extends ValidType[])] ? { [Index in keyof Ar]: SimpleType<Ar[Index]> }
  : T extends { [K in 'data']?: infer Ob extends ValidType } ? SimpleType<Ob>
  : T extends { [K in 'attributes']?: infer Ob extends ValidType } ? SimpleType<Ob>
  : T extends Omit<ObjectType, 'data' | 'attributes'> ? { [key in Exclude<keyof T, '__typename'>]: SimpleType<T[key]> }
  : T)

type IsUnion<T, U extends T = T> = (T extends any ? (U extends T ? false : true) : never) extends false ? false : true
type GetOnlyKeyOrNever<T extends ObjectType, Keys = Exclude<keyof T, '__typename'>> = IsUnion<Keys> extends true ? never : Keys

export type SimpleResponse<T extends ObjectType> = SimpleType<T[GetOnlyKeyOrNever<T>]>
export type NonNullableItem<T extends any[] | null | undefined> = NonNullable<NonNullable<T>[number]>

What does it do? An example

simplifyResponse() Transforms this:

{
  "detailsBeaches": {
    "__typename": "DetailsBeachEntityResponseCollection",
    "data": [
      {
        "__typename": "DetailsBeachEntity",
        "attributes": {
          "__typename": "DetailsBeach",
          "name": "Aigua blava",
          "basicDetails": {
            "__typename": "ComponentPlaceDetailsBasicDetails",
            "shortDescription": "Lorem ipsum...",
            "cover": {
              "__typename": "UploadFileEntityResponse",
              "data": {
                "__typename": "UploadFileEntity",
                "attributes": {
                  "__typename": "UploadFile",
                  "url": "/uploads/Aiguablava_19_ecbb012937.jpg",
                  "height": 768,
                  "width": 1413
                }
              }
            }
          }
        }
      }
    ]
  }
}

Into this:

[
  {
    "name": "Aigua blava",
    "basicDetails": {
      "shortDescription": "Lorem ipsum...",
      "cover": {
        "url": "/uploads/Aiguablava_19_ecbb012937.jpg",
        "height": 768,
        "width": 1413
      }
    }
  }
]

Notice that the first object with only one key gets "unwraped", in this case the key detailsBeaches is gone.

And automatically infers proper types. 🎉

simplify() does the same, but doesn't remove the first object key.

The exported utility types are:

  • SimpleResponse: Return type of simplifyResponse() function
  • SimpleType: Return type of simplify() function
  • NonNullableItem: Used to access the first item of a response that returns a list and removes nulls/undefineds.
  • NonNullable: Well, this is not exported, and it's native to typescript, but is useful. Removes nulls/undefineds.

Usage example

GraphQL query returns an array, but we just want the first item:

// /src/app/beaches/[slug]/page.tsx  <-- Just an example, yours will be different

import { GetBeachQuery, graphql, gqlClient } from 'src/lib/gql/__generated_code_from_codegen__'
import { simplifyResponse, SimpleResponse, NonNullableItem } from 'src/lib/gql'

const getBeachQuery = graphql(`
  query getBeach($slug: String!, $locale: I18NLocaleCode!) {
    detailsBeaches(filters: { slug: { eq: $slug } }, locale: $locale) {
      data {
        attributes {
          name
          basicDetails {
            shortDescription
            cover {
              data {
                attributes {
                  url
                  height
                  width
                }
              }
            }
          }
        }
      }
    }
  }
`)

export default async function PageWrapper({
  params: { slug },
}: {
  params: { slug: string }
}) {
  const locale = useLocale()

  const { data } = await gqlClient().query({
    query: getBeachQuery,
    variables: { locale, slug },
  })
  const beaches = simplifyResponse(data)
  const beach = beaches?.[0]

  if (!beach) notFound()

  return <Page beach={beach} />
}

// Notice the custom `NonNullableItem` utility type wrapping the `SimpleResponse` to acces the array item and remove nulls at the same time
function Page({ beach }: { beach: NonNullableItem<SimpleResponse<GetBeachQuery>> }) {
  return (
    <h2>{beach.name}</h2>
    <img src={beach.basicDetails?.cover?.url} alt="Beach image" />
  )
}

GraphQL returns an object

// /src/app/beaches/page.tsx  <-- Just an example, yours will be different

import { GetAllBeachesQuery, graphql, gqlClient } from 'src/lib/gql/__generated_code_from_codegen__'
import { simplifyResponse, SimpleResponse } from 'src/lib/gql'

const getAllBeachesQuery = graphql(`
  query getAllBeaches($locale: I18NLocaleCode!) {
    detailsBeaches(locale: $locale) {
      data {
        attributes {
          name
          slug
        }
      }
    }
  }
`)

export default async function PageWrapper() {
  const locale = useLocale()

  const { data } = await gqlClient().query({
    query: getAllBeachesQuery,
    variables: { locale },
  })

  const beaches = simplifyResponse(data)

  if (!beaches) return <h1>Error fetching data</h1> // TODO: Do better error handling

  return <Page beaches={beaches} />
}

// Notice the TypeScript native `NonNullable` utility type wrapping the `SimpleResponse` to remove nulls
function Page({ beaches }: { beaches: NonNullable<SimpleResponse<GetAllBeachesQuery>> }) {
  return (
      <ul>
        {beaches.map((beach) => beach && (
          <li key={beach.slug}>
            <Link
              href={{
                pathname: '/beaches/[slug]',
                params: { slug: beach.slug ?? 'null' },
              }}
            >{beach.name}</Link>
          </li>
        ))}
      </ul>
  )
}

You're welcome :D

@jonasmarco
Copy link

It's me again, this is the script I ended up doing. The code is not clean, but the exported methods let the code where it's used be clean. Could be improved because I did it in an afternoon for a new project and didn't use it in a large codebase to find edge cases, but for a basic usage works. Maybe I give updates about the new use cases I find.

Type inference works perfectly.

Basically, you place this file wherever you want in the frontend. Not in strapi backend, and call it after the GraphQL request to parse the results into a readable format while keeping the types.

// src/lib/gql/index.ts <-- You can chose another location

/* eslint-disable @typescript-eslint/no-explicit-any */

export function simplifyResponse<T extends ObjectType>(response: T): SimpleResponse<T> {
  const entries = Object.entries(response).filter(([k]) => k !== '__typename')
  if (entries.length >= 2) throw new Error('Cannot simplify a Strapi response that contains an object with more than one key')
  return simplify(entries[0][1] as any)
}

export function simplify<T extends ValidType>(value: T): SimpleType<T>
export function simplify<T>(value: T) {
  if (Array.isArray(value)) return value.map(simplify)

  if (isPlainObject(value)) {
    if ('data' in value) return simplify(value.data)
    if ('attributes' in value) return simplify(value.attributes)
    return objectMap(value, simplify)
  }

  return value
}

function isPlainObject<O extends R | any, R extends Record<string | number | symbol, any>>(obj: O): obj is R {
  return typeof obj === 'object' && obj !== null && obj.constructor === Object && Object.getPrototypeOf(obj) === Object.prototype;
}

interface Dictionary<T> {
  [key: string]: T;
}

function objectMap<TValue, TResult>(
  obj: Dictionary<TValue>,
  valSelector: (val: TValue, obj: Dictionary<TValue>) => TResult,
  keySelector?: (key: string, obj: Dictionary<TValue>) => string,
  ctx?: Dictionary<TValue>
) {
  const ret = {} as Dictionary<TResult>;
  for (const key of Object.keys(obj)) {
    if (key === '__typename') continue;
    const retKey = keySelector
      ? keySelector.call(ctx || null, key, obj)
      : key;
    const retVal = valSelector.call(ctx || null, obj[key], obj);
    ret[retKey] = retVal;
  }
  return ret;
}

type ValidType = UntouchedType | ObjectType | ArrayType

type UntouchedType = boolean | number | string | symbol | null | undefined | bigint | Date
type ObjectType = { [key in string]?: ValidType }
type ArrayType = ValidType[]

type IsAny<T> = unknown extends T & string ? true : false;

export type SimpleType<T extends ValidType> = IsAny<T> extends true ? any : (T extends UntouchedType ? T
  : T extends [...(infer Ar extends ValidType[])] ? { [Index in keyof Ar]: SimpleType<Ar[Index]> }
  : T extends { [K in 'data']?: infer Ob extends ValidType } ? SimpleType<Ob>
  : T extends { [K in 'attributes']?: infer Ob extends ValidType } ? SimpleType<Ob>
  : T extends Omit<ObjectType, 'data' | 'attributes'> ? { [key in Exclude<keyof T, '__typename'>]: SimpleType<T[key]> }
  : T)

type IsUnion<T, U extends T = T> = (T extends any ? (U extends T ? false : true) : never) extends false ? false : true
type GetOnlyKeyOrNever<T extends ObjectType, Keys = Exclude<keyof T, '__typename'>> = IsUnion<Keys> extends true ? never : Keys

export type SimpleResponse<T extends ObjectType> = SimpleType<T[GetOnlyKeyOrNever<T>]>
export type NonNullableItem<T extends any[] | null | undefined> = NonNullable<NonNullable<T>[number]>

What does it do? An example

simplifyResponse() Transforms this:

{
  "detailsBeaches": {
    "__typename": "DetailsBeachEntityResponseCollection",
    "data": [
      {
        "__typename": "DetailsBeachEntity",
        "attributes": {
          "__typename": "DetailsBeach",
          "name": "Aigua blava",
          "basicDetails": {
            "__typename": "ComponentPlaceDetailsBasicDetails",
            "shortDescription": "Lorem ipsum...",
            "cover": {
              "__typename": "UploadFileEntityResponse",
              "data": {
                "__typename": "UploadFileEntity",
                "attributes": {
                  "__typename": "UploadFile",
                  "url": "/uploads/Aiguablava_19_ecbb012937.jpg",
                  "height": 768,
                  "width": 1413
                }
              }
            }
          }
        }
      }
    ]
  }
}

Into this:

[
  {
    "name": "Aigua blava",
    "basicDetails": {
      "shortDescription": "Lorem ipsum...",
      "cover": {
        "url": "/uploads/Aiguablava_19_ecbb012937.jpg",
        "height": 768,
        "width": 1413
      }
    }
  }
]

Notice that the first object with only one key gets "unwraped", in this case the key detailsBeaches is gone.

And automatically infers proper types. 🎉

simplify() does the same, but doesn't remove the first object key.

The exported utility types are:

  • SimpleResponse: Return type of simplifyResponse() function
  • SimpleType: Return type of simplify() function
  • NonNullableItem: Used to access the first item of a response that returns a list and removes nulls/undefineds.
  • NonNullable: Well, this is not exported, and it's native to typescript, but is useful. Removes nulls/undefineds.

Usage example

GraphQL query returns an array, but we just want the first item:

// /src/app/beaches/[slug]/page.tsx  <-- Just an example, yours will be different

import { GetBeachQuery, graphql, gqlClient } from 'src/lib/gql/__generated_code_from_codegen__'
import { simplifyResponse, SimpleResponse, NonNullableItem } from 'src/lib/gql'

const getBeachQuery = graphql(`
  query getBeach($slug: String!, $locale: I18NLocaleCode!) {
    detailsBeaches(filters: { slug: { eq: $slug } }, locale: $locale) {
      data {
        attributes {
          name
          basicDetails {
            shortDescription
            cover {
              data {
                attributes {
                  url
                  height
                  width
                }
              }
            }
          }
        }
      }
    }
  }
`)

export default async function PageWrapper({
  params: { slug },
}: {
  params: { slug: string }
}) {
  const locale = useLocale()

  const { data } = await gqlClient().query({
    query: getBeachQuery,
    variables: { locale, slug },
  })
  const beaches = simplifyResponse(data)
  const beach = beaches?.[0]

  if (!beach) notFound()

  return <Page beach={beach} />
}

// Notice the custom `NonNullableItem` utility type wrapping the `SimpleResponse` to acces the array item and remove nulls at the same time
function Page({ beach }: { beach: NonNullableItem<SimpleResponse<GetBeachQuery>> }) {
  return (
    <h2>{beach.name}</h2>
    <img src={beach.basicDetails?.cover?.url} alt="Beach image" />
  )
}

GraphQL returns an object

// /src/app/beaches/page.tsx  <-- Just an example, yours will be different

import { GetAllBeachesQuery, graphql, gqlClient } from 'src/lib/gql/__generated_code_from_codegen__'
import { simplifyResponse, SimpleResponse } from 'src/lib/gql'

const getAllBeachesQuery = graphql(`
  query getAllBeaches($locale: I18NLocaleCode!) {
    detailsBeaches(locale: $locale) {
      data {
        attributes {
          name
          slug
        }
      }
    }
  }
`)

export default async function PageWrapper() {
  const locale = useLocale()

  const { data } = await gqlClient().query({
    query: getAllBeachesQuery,
    variables: { locale },
  })

  const beaches = simplifyResponse(data)

  if (!beaches) return <h1>Error fetching data</h1> // TODO: Do better error handling

  return <Page beaches={beaches} />
}

// Notice the TypeScript native `NonNullable` utility type wrapping the `SimpleResponse` to remove nulls
function Page({ beaches }: { beaches: NonNullable<SimpleResponse<GetAllBeachesQuery>> }) {
  return (
      <ul>
        {beaches.map((beach) => beach && (
          <li key={beach.slug}>
            <Link
              href={{
                pathname: '/beaches/[slug]',
                params: { slug: beach.slug ?? 'null' },
              }}
            >{beach.name}</Link>
          </li>
        ))}
      </ul>
  )
}

You're welcome :D

🥺

image

@mauriciabad
Copy link

@jonasmarco It works, check that the code was copied well and also your project's tsconfig.json.

Code working in the TS playground.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment