Created
May 9, 2023 19:13
-
-
Save furf/f2e706e5790a8cc9da27f8c7987e6162 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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