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',
];
@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