Skip to content

Commit a982c1f

Browse files
author
Steven Petryk
authored
feat(rendering): add a "fields-only" mode for validating responses (#9)
At Intercom, we have a need to validate Contentful responses to ensure they match the schema constructed by the codegen. This will prevent our marketing site from building if Contentful content is unexpectedly unpublished, the validations are messed up, or (hopefully) any other discrepancy. This flag is intended to support the use case where you want to ensure that all of the fields in the Contentful response are well-formed, but don't want to validate auxiliary things like Sys, Assets, etc. * Improve test coverage * Fix weird whitespace * Fix MORE weird whitespace * Use Jest CI config * Add a semicolon
1 parent f889a61 commit a982c1f

17 files changed

+508
-60
lines changed

.circleci/config.yml

+1-2
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ jobs:
3333

3434
- run:
3535
name: Run tests
36-
command: yarn test --ci
37-
36+
command: yarn test:ci
3837

3938
release:
4039
<<: *defaults

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'",
2323
"start": "rollup -c rollup.config.ts -w",
2424
"test": "jest --coverage",
25+
"test:ci": "jest --coverage --max-workers=2 --ci",
2526
"test:prod": "npm run lint && npm run test -- --no-cache",
2627
"test:watch": "jest --coverage --watch",
2728
"semantic-release": "semantic-release"

src/contentful-typescript-codegen.ts

+22-7
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
import render from "./renderers/render"
2+
import renderFieldsOnly from "./renderers/renderFieldsOnly"
23
import path from "path"
34
import { outputFileSync } from "fs-extra"
45

56
const meow = require("meow")
67

78
const cli = meow(
89
`
9-
Usage
10-
$ contentful-typescript-codegen --output <file> <options>
10+
Usage
11+
$ contentful-typescript-codegen --output <file> <options>
1112
12-
Options
13-
--output, -o Where to write to
13+
Options
14+
--output, -o Where to write to
1415
--poll, -p Continuously refresh types
1516
--interval N, -i The interval in seconds at which to poll (defaults to 15)
17+
--fields-only Output a tree that _only_ ensures fields are valid
18+
and present, and does not provide types for Sys,
19+
Assets, or Rich Text. This is useful for ensuring raw
20+
Contentful responses will be compatible with your code.
1621
17-
Examples
18-
$ contentful-typescript-codegen -o src/@types/generated/contentful.d.ts
22+
Examples
23+
$ contentful-typescript-codegen -o src/@types/generated/contentful.d.ts
1924
`,
2025
{
2126
flags: {
@@ -24,6 +29,10 @@ const cli = meow(
2429
alias: "o",
2530
required: true,
2631
},
32+
fieldsOnly: {
33+
type: "boolean",
34+
required: false,
35+
},
2736
poll: {
2837
type: "boolean",
2938
alias: "p",
@@ -44,9 +53,15 @@ async function runCodegen(outputFile: string) {
4453
const environment = await getEnvironment()
4554
const contentTypes = await environment.getContentTypes({ limit: 1000 })
4655
const locales = await environment.getLocales()
47-
const output = await render(contentTypes.items, locales.items)
4856
const outputPath = path.resolve(process.cwd(), outputFile)
4957

58+
let output
59+
if (cli.flags.fieldsOnly) {
60+
output = await renderFieldsOnly(contentTypes.items)
61+
} else {
62+
output = await render(contentTypes.items, locales.items)
63+
}
64+
5065
outputFileSync(outputPath, output)
5166
}
5267

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Field } from "contentful"
2+
import renderSymbol from "../../contentful/fields/renderSymbol"
3+
import renderLink from "../../contentful-fields-only/fields/renderLink"
4+
import renderArrayOf from "../../typescript/renderArrayOf"
5+
6+
export default function renderArray(field: Field): string {
7+
if (!field.items) {
8+
throw new Error(`Cannot render non-array field ${field.id} as an array`)
9+
}
10+
11+
const fieldWithValidations: Field = {
12+
...field,
13+
linkType: field.items.linkType,
14+
validations: field.items.validations || [],
15+
}
16+
17+
switch (field.items.type) {
18+
case "Symbol": {
19+
return renderArrayOf(renderSymbol(fieldWithValidations))
20+
}
21+
22+
case "Link": {
23+
return renderArrayOf(renderLink(fieldWithValidations))
24+
}
25+
}
26+
27+
return renderArrayOf("unknown")
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Field } from "contentful"
2+
import renderContentTypeId from "../../contentful/renderContentTypeId"
3+
import { renderUnionValues } from "../../typescript/renderUnion"
4+
5+
export default function renderLink(field: Field): string {
6+
if (field.linkType === "Asset") {
7+
return "any"
8+
}
9+
10+
if (field.linkType === "Entry") {
11+
const contentTypeValidation = field.validations.find(validation => !!validation.linkContentType)
12+
13+
if (contentTypeValidation) {
14+
return renderUnionValues(contentTypeValidation.linkContentType!.map(renderContentTypeId))
15+
} else {
16+
return "unknown"
17+
}
18+
}
19+
20+
return "unknown"
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Field } from "contentful"
2+
3+
export default function renderRichText(field: Field): string {
4+
return "any"
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { ContentType, Field, FieldType } from "contentful"
2+
3+
import renderInterface from "../typescript/renderInterface"
4+
import renderField from "../contentful/renderField"
5+
import renderContentTypeId from "../contentful/renderContentTypeId"
6+
7+
import renderArray from "../contentful-fields-only/fields/renderArray"
8+
import renderLink from "../contentful-fields-only/fields/renderLink"
9+
import renderRichText from "../contentful-fields-only/fields/renderRichText"
10+
11+
import renderBoolean from "../contentful/fields/renderBoolean"
12+
import renderLocation from "../contentful/fields/renderLocation"
13+
import renderNumber from "../contentful/fields/renderNumber"
14+
import renderObject from "../contentful/fields/renderObject"
15+
import renderSymbol from "../contentful/fields/renderSymbol"
16+
17+
export default function renderContentType(contentType: ContentType): string {
18+
const name = renderContentTypeId(contentType.sys.id)
19+
const fields = renderContentTypeFields(contentType.fields)
20+
21+
return renderInterface({
22+
name,
23+
fields: `
24+
fields: { ${fields} };
25+
[otherKeys: string]: any;
26+
`,
27+
})
28+
}
29+
30+
function renderContentTypeFields(fields: Field[]): string {
31+
return fields
32+
.filter(field => !field.omitted)
33+
.map<string>(field => {
34+
const functionMap: Record<FieldType, (field: Field) => string> = {
35+
Array: renderArray,
36+
Boolean: renderBoolean,
37+
Date: renderSymbol,
38+
Integer: renderNumber,
39+
Link: renderLink,
40+
Location: renderLocation,
41+
Number: renderNumber,
42+
Object: renderObject,
43+
RichText: renderRichText,
44+
Symbol: renderSymbol,
45+
Text: renderSymbol,
46+
}
47+
48+
return renderField(field, functionMap[field.type](field))
49+
})
50+
.join("\n\n")
51+
}

src/renderers/contentful/renderContentType.ts

+19-7
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,25 @@ import renderObject from "./fields/renderObject"
1313
import renderRichText from "./fields/renderRichText"
1414
import renderSymbol from "./fields/renderSymbol"
1515

16-
export default function renderContentType(contentType: ContentType) {
17-
return renderInterface(
18-
renderContentTypeId(contentType.sys.id),
19-
renderContentTypeFields(contentType.fields),
20-
contentType.description,
21-
renderSys(contentType.sys),
22-
)
16+
export default function renderContentType(contentType: ContentType): string {
17+
const name = renderContentTypeId(contentType.sys.id)
18+
const fields = renderContentTypeFields(contentType.fields)
19+
const sys = renderSys(contentType.sys)
20+
21+
return `
22+
${renderInterface({ name: `${name}Fields`, fields })}
23+
24+
${descriptionComment(contentType.description)}
25+
${renderInterface({ name, extension: `Entry<${name}Fields>`, fields: sys })}
26+
`
27+
}
28+
29+
function descriptionComment(description: string | undefined) {
30+
if (description) {
31+
return `/** ${description} */`
32+
}
33+
34+
return ""
2335
}
2436

2537
function renderContentTypeFields(fields: Field[]): string {

src/renderers/renderFieldsOnly.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ContentType } from "contentful"
2+
3+
import { format } from "prettier"
4+
5+
import renderContentType from "./contentful-fields-only/renderContentType"
6+
7+
export default async function renderFieldsOnly(contentTypes: ContentType[]) {
8+
const sortedContentTypes = contentTypes.sort((a, b) => a.sys.id.localeCompare(b.sys.id))
9+
10+
const source = renderAllContentTypes(sortedContentTypes)
11+
12+
return format(source, { parser: "typescript" })
13+
}
14+
15+
function renderAllContentTypes(contentTypes: ContentType[]): string {
16+
return contentTypes.map(contentType => renderContentType(contentType)).join("\n\n")
17+
}
+14-21
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,18 @@
1-
export default function renderInterface(
2-
name: string,
3-
fields: string,
4-
description?: string,
5-
contents?: string,
6-
): string {
1+
export default function renderInterface({
2+
name,
3+
extension,
4+
fields,
5+
description,
6+
}: {
7+
name: string
8+
extension?: string
9+
fields: string
10+
description?: string
11+
}) {
712
return `
8-
export interface ${name}Fields {
13+
${description ? `/** ${description} */` : ""}
14+
export interface ${name} ${extension ? `extends ${extension}` : ""} {
915
${fields}
10-
};
11-
12-
${descriptionComment(description)}
13-
export interface ${name} extends Entry<${name}Fields> {
14-
${contents || ""}
15-
};
16+
}
1617
`
1718
}
18-
19-
function descriptionComment(description: string | undefined) {
20-
if (description) {
21-
return `/** ${description} */`
22-
}
23-
24-
return ""
25-
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import renderArray from "../../../../src/renderers/contentful-fields-only/fields/renderArray"
2+
import { Field } from "contentful"
3+
4+
describe("renderArray()", () => {
5+
it("renders an array of symbols", () => {
6+
const arrayOfSymbols: Field = {
7+
type: "Array",
8+
id: "fieldId",
9+
name: "Field Name",
10+
validations: [],
11+
omitted: false,
12+
required: true,
13+
disabled: false,
14+
linkType: undefined,
15+
localized: false,
16+
items: {
17+
type: "Symbol",
18+
validations: [],
19+
},
20+
}
21+
22+
expect(renderArray(arrayOfSymbols)).toMatchInlineSnapshot(`"(string)[]"`)
23+
})
24+
25+
it("renders an array of symbols with validations", () => {
26+
const arrayOfValidatedSymbols: Field = {
27+
type: "Array",
28+
id: "fieldId",
29+
name: "Field Name",
30+
validations: [],
31+
omitted: false,
32+
required: true,
33+
disabled: false,
34+
linkType: undefined,
35+
localized: false,
36+
items: {
37+
type: "Symbol",
38+
validations: [{ in: ["one", "of", "these"] }],
39+
},
40+
}
41+
42+
expect(renderArray(arrayOfValidatedSymbols)).toMatchInlineSnapshot(
43+
`"('one' | 'of' | 'these')[]"`,
44+
)
45+
})
46+
47+
it("renders an array of links of a particular type", () => {
48+
const arrayOfValidatedSymbols: Field = {
49+
type: "Array",
50+
id: "fieldId",
51+
name: "Field Name",
52+
validations: [],
53+
omitted: false,
54+
required: true,
55+
disabled: false,
56+
linkType: undefined,
57+
localized: false,
58+
items: {
59+
type: "Link",
60+
linkType: "Entry",
61+
validations: [{ linkContentType: ["contentType1", "contentType2"] }],
62+
},
63+
}
64+
65+
expect(renderArray(arrayOfValidatedSymbols)).toMatchInlineSnapshot(
66+
`"(IContentType1 | IContentType2)[]"`,
67+
)
68+
})
69+
})

0 commit comments

Comments
 (0)