@financial-times/cp-content-pipeline-schema 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/lib/datasources/capi.d.ts +2 -4
  3. package/lib/datasources/capi.js +8 -12
  4. package/lib/datasources/capi.js.map +1 -1
  5. package/lib/datasources/instrumented.d.ts +5 -5
  6. package/lib/datasources/instrumented.js +36 -27
  7. package/lib/datasources/instrumented.js.map +1 -1
  8. package/lib/datasources/origami-image.d.ts +2 -3
  9. package/lib/datasources/origami-image.js +3 -3
  10. package/lib/datasources/origami-image.js.map +1 -1
  11. package/lib/datasources/twitter.d.ts +2 -2
  12. package/lib/datasources/twitter.js +3 -3
  13. package/lib/datasources/twitter.js.map +1 -1
  14. package/lib/fixtures/dummyContext.js +6 -5
  15. package/lib/fixtures/dummyContext.js.map +1 -1
  16. package/lib/model/CapiResponse.d.ts +1 -50
  17. package/lib/model/CapiResponse.js +43 -20
  18. package/lib/model/CapiResponse.js.map +1 -1
  19. package/lib/resolvers/content-tree/bodyXMLToTree.d.ts +1 -1
  20. package/lib/resolvers/content-tree/tagMappings.d.ts +2 -1
  21. package/lib/resolvers/content-tree/tagMappings.js +1 -1
  22. package/lib/resolvers/content-tree/tagMappings.js.map +1 -1
  23. package/lib/resolvers/content-tree/tagMappings.test.js +9 -2
  24. package/lib/resolvers/content-tree/tagMappings.test.js.map +1 -1
  25. package/lib/types/cache.d.ts +1 -1
  26. package/lib/types/cache.js +4 -1
  27. package/lib/types/cache.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/datasources/capi.ts +9 -15
  30. package/src/datasources/instrumented.ts +62 -43
  31. package/src/datasources/origami-image.ts +5 -9
  32. package/src/datasources/twitter.ts +5 -11
  33. package/src/fixtures/dummyContext.ts +7 -5
  34. package/src/model/CapiResponse.ts +68 -29
  35. package/src/resolvers/content-tree/bodyXMLToTree.ts +1 -1
  36. package/src/resolvers/content-tree/tagMappings.test.ts +18 -3
  37. package/src/resolvers/content-tree/tagMappings.ts +6 -2
  38. package/src/types/cache.ts +6 -2
  39. package/tsconfig.tsbuildinfo +1 -1
@@ -30,6 +30,15 @@ import {
30
30
 
31
31
  import { ContentPackageContainsArgs, Media } from '../generated'
32
32
  import { Topper } from './Topper'
33
+ import { z } from 'zod'
34
+
35
+ function isPlainObject(object: unknown): object is Record<string, unknown> {
36
+ if (object && typeof object === 'object') {
37
+ return true
38
+ }
39
+
40
+ return false
41
+ }
33
42
 
34
43
  type Design = {
35
44
  theme: LiteralUnionScalarValues<typeof PackageDesign>
@@ -56,7 +65,7 @@ type ZodInputObject = {
56
65
 
57
66
  function flattenFormattedZodIssues(
58
67
  issues: FormattedZodIssues,
59
- object: ZodInputValue,
68
+ object: unknown,
60
69
  path: string[] = [],
61
70
  flattened: string[] = []
62
71
  ): string[] {
@@ -94,7 +103,7 @@ function flattenFormattedZodIssues(
94
103
  key !== '_errors' &&
95
104
  !Array.isArray(value) &&
96
105
  object &&
97
- (Array.isArray(object) || typeof object === 'object')
106
+ (Array.isArray(object) || isPlainObject(object))
98
107
  ) {
99
108
  const isArrayKey = !Number.isNaN(parseInt(key))
100
109
  flattenFormattedZodIssues(
@@ -109,6 +118,36 @@ function flattenFormattedZodIssues(
109
118
  return flattened
110
119
  }
111
120
 
121
+ function getContentType(
122
+ content: { type?: string; types: string[] },
123
+ validate?: true
124
+ ): LiteralUnionScalarValues<typeof ContentType>
125
+ function getContentType(
126
+ content: { type?: string; types: string[] },
127
+ validate: false
128
+ ): LiteralUnionScalarValues<typeof ContentType> | string
129
+ function getContentType(
130
+ content: { type?: string; types: string[] },
131
+ validate = true
132
+ ) {
133
+ const TYPE_REGEX = /https?:\/\/www.ft.com\/ontology\/content\//
134
+ const useType =
135
+ 'type' in content && content.type ? content.type : content.types[0]
136
+ const type = useType.replace(TYPE_REGEX, '')
137
+
138
+ if (validate && !validLiteralUnionValue(type, ContentType.values)) {
139
+ throw new Error('Content type is invalid: ' + useType)
140
+ }
141
+
142
+ return type
143
+ }
144
+
145
+ const baselineContentSchema = z.object({
146
+ id: z.string(),
147
+ type: z.string().optional(),
148
+ types: z.array(z.string()),
149
+ })
150
+
112
151
  export class CapiResponse {
113
152
  constructor(
114
153
  private capiData: ContentTypeSchemas,
@@ -117,11 +156,15 @@ export class CapiResponse {
117
156
  ) {}
118
157
 
119
158
  static fromJSON(
120
- content: any,
159
+ content: unknown,
121
160
  context: QueryContext,
122
161
  packageContainer?: CapiResponse
123
162
  ): CapiResponse {
124
- const model = new CapiResponse(content, context, packageContainer)
163
+ // if the content doesn't meet the baseline schema, something's gone
164
+ // unexpectedly very wrong. throwing an non-operational error is the
165
+ // right thing to do in that case, so we call .parse not .safeParse
166
+ const baseContent = baselineContentSchema.parse(content)
167
+ const type = getContentType(baseContent)
125
168
 
126
169
  /*
127
170
  * Check if the incoming data matches the types defined in our data source schema
@@ -129,7 +172,8 @@ export class CapiResponse {
129
172
  * As there is no agreed schema provided by CAPI there is a chance it will be out of date / incorrect at times
130
173
  * Manual updates will be required at times to keep it in sync with the responses we receive
131
174
  */
132
- const schemaResponse = model.schema().safeParse(content)
175
+ const schemaResponse = schemas(type).safeParse(content)
176
+
133
177
  /*
134
178
  * Log an error if the response fails validation
135
179
  * Logs should be used to warn us of issues or response changes
@@ -146,32 +190,31 @@ export class CapiResponse {
146
190
  schemaResponse.error.format(),
147
191
  content
148
192
  ),
149
- contentId: content.id,
150
- contentType: model.type(false),
193
+ contentId: baseContent.id,
194
+ contentType: type,
151
195
  }),
152
196
  })
153
197
 
154
198
  context.metrics?.count(
155
- `graphql.datasource.CapiDataSource.${model.type(
156
- false
157
- )}.validation.failure.count`,
199
+ `graphql.datasource.CapiDataSource.${type}.validation.failure.count`,
158
200
  1
159
201
  )
160
202
  } else {
161
203
  context.metrics?.count(
162
- `graphql.datasource.CapiDataSource.${model.type(
163
- false
164
- )}.validation.success.count`,
204
+ `graphql.datasource.CapiDataSource.${type}.validation.success.count`,
165
205
  1
166
206
  )
167
207
  }
168
208
 
169
- return model
209
+ // we can "safely" cast content to the schema return types here. if the type is
210
+ // incorrect, we've at least logged that error.
211
+ return new CapiResponse(
212
+ content as ContentTypeSchemas,
213
+ context,
214
+ packageContainer
215
+ )
170
216
  }
171
217
 
172
- schema() {
173
- return schemas(this.type())
174
- }
175
218
  bodyXML(): string | null {
176
219
  if ('bodyXML' in this.capiData) {
177
220
  return this.capiData.bodyXML
@@ -281,7 +324,10 @@ export class CapiResponse {
281
324
  return this.capiData.title
282
325
  }
283
326
  id() {
284
- return uuidFromUrl(this.capiData.id)
327
+ const uuid = uuidFromUrl(this.capiData.id)
328
+ this.context.addSurrogateKeys([uuid])
329
+
330
+ return uuid
285
331
  }
286
332
  standfirst(): string | null {
287
333
  if ('standfirst' in this.capiData && this.capiData.standfirst) {
@@ -410,18 +456,11 @@ export class CapiResponse {
410
456
  type(validate?: true): LiteralUnionScalarValues<typeof ContentType>
411
457
  type(validate: false): LiteralUnionScalarValues<typeof ContentType> | string
412
458
  type(validate = true) {
413
- const TYPE_REGEX = /https?:\/\/www.ft.com\/ontology\/content\//
414
- const useType =
415
- 'type' in this.capiData && this.capiData.type
416
- ? this.capiData.type
417
- : this.capiData.types[0]
418
- const type = useType.replace(TYPE_REGEX, '')
419
-
420
- if (validate && !validLiteralUnionValue(type, ContentType.values)) {
421
- throw new Error('Content type is invalid: ' + useType)
459
+ if (validate) {
460
+ return getContentType(this.capiData, true)
461
+ } else {
462
+ return getContentType(this.capiData, false)
422
463
  }
423
-
424
- return type
425
464
  }
426
465
 
427
466
  topperHasImage() {
@@ -10,7 +10,7 @@ function isNode(node: ContentTree.Node | undefined): node is ContentTree.Node {
10
10
  }
11
11
 
12
12
  type ContentTreeTransform = (
13
- $el: cheerio.Cheerio<any>,
13
+ $el: cheerio.Cheerio<cheerio.Element>,
14
14
  traverse: () => AnyNode[],
15
15
  context?: QueryContext
16
16
  ) => AnyNode | AnyNode[]
@@ -1,5 +1,19 @@
1
+ import { AnyNode, OldClip } from './Workarounds'
1
2
  import tagMappings, { getBooleanAttributeValue } from './tagMappings'
2
3
  import cheerio from 'cheerio'
4
+
5
+ function expectNotArray<T>(thing: T | T[]): asserts thing is T {
6
+ expect(thing).not.toBeInstanceOf(Array)
7
+ }
8
+
9
+ function expectNodeType<T extends AnyNode>(
10
+ node: AnyNode | AnyNode[],
11
+ type: T['type']
12
+ ): asserts node is T {
13
+ expectNotArray(node)
14
+ expect(node.type).toBe(type)
15
+ }
16
+
3
17
  describe('tagMappings test', () => {
4
18
  it('getBooleanAttributeValue attrubute', () => {
5
19
  let $el = cheerio.load('<ft-content autoplay></ft-content>')('ft-content')
@@ -20,7 +34,7 @@ describe('tagMappings test', () => {
20
34
  const bodyXML = `
21
35
  <body>
22
36
  <experimental>
23
- <ft-content autoplay=\"false\" caption=\"caption data\" data-asset-type=\"clip\" data-copyright=\"Reuters\"
37
+ <ft-content autoplay=\"false\" caption=\"caption data\" data-asset-type=\"clip\" data-copyright=\"Reuters\"
24
38
  data-layout=\"in-line\" description=\"description\" href=\"https://storytelling-clips.s3.amazonaws.com/World/Joe%20Biden%20on%20debt%20ceiling%20deal%20v1.mp4\"
25
39
  loop=\"true\" muted=\"false\" poster=\"https://d1e00ek4ebabms.cloudfront.net/production/65233b7d-5260-4acc-b01c-38ff470068e0.jpg\"
26
40
  poster-alt=\"\" poster-copyright=\"Reuters\" poster-id=\"db0a46c6-9e60-4f46-bc0b-c158cb8394d0\" type=\"http://www.ft.com/ontology/content/clip\">
@@ -30,8 +44,9 @@ describe('tagMappings test', () => {
30
44
  const selector =
31
45
  'ft-content[type="http://www.ft.com/ontology/content/clip"]'
32
46
  const $el = cheerio.load(bodyXML)(selector)
33
- const mapping: any = tagMappings[selector]($el, () => [])
34
- expect(mapping.type).toBe('clip')
47
+ const mapping = tagMappings[selector]($el, () => [])
48
+
49
+ expectNodeType<OldClip>(mapping, 'clip')
35
50
  expect(mapping.url).toBe(
36
51
  'https://storytelling-clips.s3.amazonaws.com/World/Joe%20Biden%20on%20debt%20ceiling%20deal%20v1.mp4'
37
52
  )
@@ -20,6 +20,7 @@ import {
20
20
  import * as scrollytelling from '@financial-times/n-scrollytelling-image/server'
21
21
  import { LiteralToPrimitiveDeep } from 'type-fest'
22
22
  import { CapiResponse } from '../../model/CapiResponse'
23
+ import type { Cheerio, Element } from 'cheerio'
23
24
 
24
25
  const tableResponsiveStyleMap: Record<string, Table['responsiveStyle']> = {
25
26
  stacked: 'flat',
@@ -623,8 +624,11 @@ const commonTagMappings: TagMappings = {
623
624
  // We can't trust cheerio.attr() method since with attributes like "autoplay"
624
625
  //doesn't return the value instead return "autoplay" and "autoplay" = "false"
625
626
  //should be interpreted as false
626
- export const getBooleanAttributeValue = ($el: any, attribute: string) => {
627
- const value = $el.get(0).attribs[attribute]
627
+ export const getBooleanAttributeValue = (
628
+ $el: Cheerio<Element>,
629
+ attribute: string
630
+ ) => {
631
+ const value = $el.get(0)?.attribs[attribute]
628
632
  return [null, undefined, 'false', false].includes(value) ? false : true
629
633
  }
630
634
 
@@ -11,7 +11,11 @@ interface ContextableCache<TContext extends QueryContext> {
11
11
  }
12
12
 
13
13
  export function isContextableCache<TContext extends QueryContext>(
14
- cache: any
14
+ cache: unknown
15
15
  ): cache is ContextableCache<TContext> {
16
- return 'withContext' in cache
16
+ if (cache && typeof cache === 'object' && 'withContext' in cache) {
17
+ return true
18
+ }
19
+
20
+ return false
17
21
  }