Skip to content

Instantly share code, notes, and snippets.

@furf
Created May 9, 2023 19:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save furf/f2e706e5790a8cc9da27f8c7987e6162 to your computer and use it in GitHub Desktop.
Save furf/f2e706e5790a8cc9da27f8c7987e6162 to your computer and use it in GitHub Desktop.
const config: CodegenConfig = {
// @ts-ignore TypeScript describes this as a string, but it also supports a
// function.
schema: {
[CONTENTFUL_URI]: {
customFetch: initCustomFetch({
accessToken: CONTENTFUL_MANAGEMENT_ACCESS_TOKEN,
spaceId: CONTENTFUL_SPACE_ID,
environmentId: CONTENTFUL_ENVIRONMENT,
}),
headers: {
authorization: `Bearer ${CONTENTFUL_ACCESS_TOKEN}`,
"Content-Language": "en-us",
},
},
},
documents: ["src/**/*.(graphql|gql)"],
generates: {
"./src/graphql/contentful/__generated__/schemas.ts": {
plugins: ["typescript"],
config: {
typesPrefix: "Cf",
},
},
"./src/graphql/contentful/__generated__/operations.ts": {
preset: "import-types",
presetConfig: {
typesPath: "./schemas",
},
plugins: ["typescript-operations"],
config: {
typesPrefix: "Cf",
},
},
"./src/graphql/contentful/__generated__/hooks.tsx": {
preset: "import-types",
presetConfig: {
typesPath: "./operations",
},
plugins: ["typescript-react-apollo"],
config: {
withHOC: false,
withComponent: false,
withHooks: true,
typesPrefix: "Cf",
},
},
},
ignoreNoDocuments: true,
};
import {
createClient,
type Collection,
type ContentFields,
type ContentType,
type ContentTypeProps,
} from "contentful-management";
import type {
IntrospectionTypeRef,
IntrospectionNonNullTypeRef,
IntrospectionListTypeRef,
IntrospectionObjectType,
IntrospectionQuery,
IntrospectionType,
} from "graphql";
import fetch, { Response } from "node-fetch";
/**
* initCustomFetch
*/
type ContentfulConfig = {
accessToken: string;
spaceId: string;
environmentId: string;
};
export default function initCustomFetch(contentfulConfig: ContentfulConfig) {
return async function customFetch(url: string, config: object) {
// Load schema from GraphQL and REST APIs in parallel.
const [schema, contentTypes] = await Promise.all([
// Load Contentful schema from GraphQL API.
fetchContentfulSchema(url, config),
// Load content types from Contentful REST API.
fetchContentfulContentTypes(contentfulConfig),
]);
// Initialize required field validator.
const isValidRequiredContentTypeField =
initIsValidRequiredContentTypeField(contentTypes);
const validContentTypeNames = new Set(
contentTypes.items.map((contentType) => parseContentTypeName(contentType))
);
// Modify schema content types to enforce required fields.
schema.data.__schema.types.forEach((type) => {
if (!isIntrospectionObjectType(type)) return;
if (isContentType(type)) {
// Iterate over each field in the content type and check if it is
// required. If it is required, replace the field type with a
// non-null version of the field type.
type.fields.forEach((field) => {
if (!isValidRequiredContentTypeField(type.name, field.name)) return;
// @ts-ignore Allow assignment to read-only property.
field.type = makeNonNullType(field.type);
});
} else {
// If the type is a "collection", the items field must also be non-null.
const itemsField = type.fields.find((field) => field.name === "items");
if (
itemsField &&
isNonNullType(itemsField.type) &&
isListType(itemsField.type.ofType) &&
// @ts-ignore Find a type for the nested ofType.ofType.name
validContentTypeNames.has(itemsField.type.ofType.ofType.name)
) {
// @ts-ignore Allow assignment to read-only property.
itemsField.type.ofType.ofType = makeNonNullType(
itemsField.type.ofType.ofType
);
}
}
});
// Return the modified schema.
const buffer = Buffer.from(JSON.stringify(schema));
return new Response(buffer);
};
}
/**
* fetchContentfulSchema
*/
type IntrospectionQueryResponse = {
data: IntrospectionQuery;
};
async function fetchContentfulSchema(url: string, config: object) {
const response = await fetch(url, config);
if (!response.ok) {
throw new Error(`Bad response from API endpoint: ${response.status}.`);
}
const json = (await response.json()) as IntrospectionQueryResponse;
return json;
}
/**
* fetchContentfulContentTypes
*/
async function fetchContentfulContentTypes(config: ContentfulConfig) {
const { accessToken, spaceId, environmentId } = config;
const client = createClient({ accessToken });
const space = await client.getSpace(spaceId);
const environment = await space.getEnvironment(environmentId);
const contentTypes = await environment.getContentTypes();
return contentTypes;
}
/**
* A naive type guard to determine if a type is a GraphQL IntrospectionObjectType.
* This type is used for Contentful content types and content type collections.
*/
function isIntrospectionObjectType(
type: IntrospectionType
): type is IntrospectionObjectType {
return type.kind === "OBJECT";
}
/**
* A naive validation for Contentful content types.
*/
function isContentType(type: IntrospectionObjectType) {
return (
isIntrospectionObjectType(type) &&
type.fields.some((field) => field.name === "sys")
);
}
/**
* initIsValidRequiredContentTypeField
*/
type RequiredFields = Set<string>;
type ContentTypeRequiredField = [string, RequiredFields];
type ContentTypeRequiredFields = Map<string, RequiredFields>;
function initIsValidRequiredContentTypeField(
contentTypes: Collection<ContentType, ContentTypeProps>
) {
// Create a mapping of content type IDs to required field IDs.
const requiredContentTypeFields: ContentTypeRequiredFields = new Map(
parseRequiredContentTypeFields(contentTypes.items)
);
// Include Asset required fields.
requiredContentTypeFields.set(
"Asset",
new Set(["contentType", "fileName", "size", "url"])
);
/**
* isValidRequiredContentTypeField
*/
return function isValidRequiredContentTypeField(
contentType: string,
field: string
) {
return !!requiredContentTypeFields.get(contentType)?.has(field);
};
}
/**
* parseRequiredContentTypeFields
*/
function parseRequiredContentTypeFields(contentTypes: ContentType[]) {
// TODO determine if `__typename` should be considered a required field.
// TODO determine if `linkedFrom` should be considered a required field.
const requiredContentTypeFields: ContentTypeRequiredField[] = [];
contentTypes.forEach((contentType) => {
if (contentType.sys.type !== "ContentType") return;
const requiredFields = parseRequiredFieldIds(contentType.fields);
if (requiredFields.length === 0) return;
requiredContentTypeFields.push([
parseContentTypeName(contentType),
new Set(requiredFields),
]);
});
return requiredContentTypeFields;
}
/**
* parseRequiredFieldIds
*/
function parseRequiredFieldIds(contentFields: ContentFields[]) {
const requiredFieldIds: string[] = [];
contentFields.forEach((field) => {
if (field.required) {
requiredFieldIds.push(field.id);
}
});
return requiredFieldIds;
}
/**
* parseContentTypeName
*/
function parseContentTypeName(contentType: ContentType) {
// Capitalize the type ID to match the name property in the GraphQL schema.
return capitalize(contentType.sys.id);
}
/**
* capitalize
*/
function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* makeNonNullType
*/
type NonNullType<T extends IntrospectionTypeRef> =
IntrospectionNonNullTypeRef<T> & {
name: null;
};
function makeNonNullType<T extends IntrospectionTypeRef>(
ofType: T
): NonNullType<T> {
return {
name: null,
kind: "NON_NULL",
ofType,
};
}
/**
* isNonNullType
*/
// TODO explore Contentful's types for more specific type guards.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isNonNullType(type: any): type is IntrospectionNonNullTypeRef {
return type.kind === "NON_NULL";
}
/**
* isListType
*/
function isListType(
type: IntrospectionTypeRef
): type is IntrospectionListTypeRef {
return type.kind === "LIST";
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment