@financial-times/cp-content-pipeline-schema 2.15.0 → 3.0.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 (161) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/lib/datasources/capi.d.ts +2 -2
  3. package/lib/datasources/capi.js +4 -2
  4. package/lib/datasources/capi.js.map +1 -1
  5. package/lib/datasources/capi.test.js +2 -2
  6. package/lib/fixtures/capiObject.d.ts +2 -2
  7. package/lib/fixtures/capiObject.js +2 -0
  8. package/lib/fixtures/capiObject.js.map +1 -1
  9. package/lib/fixtures/capiPerson.d.ts +1 -1
  10. package/lib/generated/index.d.ts +296 -131
  11. package/lib/helpers/decorateHeadshotUrl.d.ts +1 -2
  12. package/lib/helpers/decorateHeadshotUrl.js +2 -3
  13. package/lib/helpers/decorateHeadshotUrl.js.map +1 -1
  14. package/lib/model/Byline.d.ts +8 -10
  15. package/lib/model/Byline.js +34 -33
  16. package/lib/model/Byline.js.map +1 -1
  17. package/lib/model/Byline.test.js +105 -52
  18. package/lib/model/Byline.test.js.map +1 -1
  19. package/lib/model/CapiResponse.d.ts +12 -16
  20. package/lib/model/CapiResponse.js +38 -41
  21. package/lib/model/CapiResponse.js.map +1 -1
  22. package/lib/model/CapiResponse.test.js +7 -18
  23. package/lib/model/CapiResponse.test.js.map +1 -1
  24. package/lib/model/Clip.d.ts +1 -1
  25. package/lib/model/Concept.d.ts +1 -1
  26. package/lib/model/Concept.js +1 -2
  27. package/lib/model/Concept.js.map +1 -1
  28. package/lib/model/FlourishSource.d.ts +1 -1
  29. package/lib/model/FlourishSource.js.map +1 -1
  30. package/lib/model/Image.d.ts +1 -1
  31. package/lib/model/LeadFlourish.test.js +1 -0
  32. package/lib/model/LeadFlourish.test.js.map +1 -1
  33. package/lib/model/Person.d.ts +6 -12
  34. package/lib/model/Person.js +39 -66
  35. package/lib/model/Person.js.map +1 -1
  36. package/lib/model/Person.test.js +7 -60
  37. package/lib/model/Person.test.js.map +1 -1
  38. package/lib/model/Picture.d.ts +1 -1
  39. package/lib/model/RichText.d.ts +1 -2
  40. package/lib/model/Topper.d.ts +1 -1
  41. package/lib/model/Topper.js +10 -8
  42. package/lib/model/Topper.js.map +1 -1
  43. package/lib/model/Topper.test.js +9 -10
  44. package/lib/model/Topper.test.js.map +1 -1
  45. package/lib/model/schemas/capi/article.d.ts +7 -4
  46. package/lib/model/schemas/capi/article.js +1 -0
  47. package/lib/model/schemas/capi/article.js.map +1 -1
  48. package/lib/model/schemas/capi/audio.d.ts +7 -4
  49. package/lib/model/schemas/capi/audio.js +1 -0
  50. package/lib/model/schemas/capi/audio.js.map +1 -1
  51. package/lib/model/schemas/capi/base-schema.d.ts +14 -123
  52. package/lib/model/schemas/capi/base-schema.js +7 -6
  53. package/lib/model/schemas/capi/base-schema.js.map +1 -1
  54. package/lib/model/schemas/capi/content-package.d.ts +10 -5
  55. package/lib/model/schemas/capi/content-package.js +2 -0
  56. package/lib/model/schemas/capi/content-package.js.map +1 -1
  57. package/lib/model/schemas/capi/index.d.ts +41 -22
  58. package/lib/model/schemas/capi/internal-content.d.ts +24 -0
  59. package/lib/model/schemas/capi/internal-content.js +3 -0
  60. package/lib/model/schemas/capi/internal-content.js.map +1 -0
  61. package/lib/model/schemas/capi/live-blog-package.d.ts +7 -4
  62. package/lib/model/schemas/capi/live-blog-package.js +1 -0
  63. package/lib/model/schemas/capi/live-blog-package.js.map +1 -1
  64. package/lib/model/schemas/capi/placeholder.d.ts +7 -4
  65. package/lib/model/schemas/capi/placeholder.js +1 -0
  66. package/lib/model/schemas/capi/placeholder.js.map +1 -1
  67. package/lib/model/schemas/capi/video.d.ts +10 -5
  68. package/lib/model/schemas/capi/video.js +2 -0
  69. package/lib/model/schemas/capi/video.js.map +1 -1
  70. package/lib/resolvers/concept.d.ts +37 -2
  71. package/lib/resolvers/concept.js +17 -10
  72. package/lib/resolvers/concept.js.map +1 -1
  73. package/lib/resolvers/content-tree/Workarounds.d.ts +19 -11
  74. package/lib/resolvers/content-tree/references/Author.d.ts +4 -0
  75. package/lib/resolvers/content-tree/references/Author.js +14 -0
  76. package/lib/resolvers/content-tree/references/Author.js.map +1 -0
  77. package/lib/resolvers/content-tree/references/ClipSet.d.ts +1 -1
  78. package/lib/resolvers/content-tree/references/ClipSet.js +1 -1
  79. package/lib/resolvers/content-tree/references/ClipSet.js.map +1 -1
  80. package/lib/resolvers/content-tree/references/Flourish.d.ts +1 -1
  81. package/lib/resolvers/content-tree/references/Reference.d.ts +1 -1
  82. package/lib/resolvers/content-tree/references/index.d.ts +4 -2
  83. package/lib/resolvers/content-tree/references/index.js +4 -1
  84. package/lib/resolvers/content-tree/references/index.js.map +1 -1
  85. package/lib/resolvers/content-tree/updateTreeWithReferenceIds.d.ts +3 -3
  86. package/lib/resolvers/content-tree/updateTreeWithReferenceIds.js +2 -3
  87. package/lib/resolvers/content-tree/updateTreeWithReferenceIds.js.map +1 -1
  88. package/lib/resolvers/content.d.ts +27 -18
  89. package/lib/resolvers/content.js +4 -2
  90. package/lib/resolvers/content.js.map +1 -1
  91. package/lib/resolvers/image.d.ts +12 -12
  92. package/lib/resolvers/index.d.ts +89 -42
  93. package/lib/resolvers/leadFlourish.d.ts +2 -1
  94. package/lib/resolvers/leadFlourish.js +1 -0
  95. package/lib/resolvers/leadFlourish.js.map +1 -1
  96. package/lib/resolvers/person.d.ts +1 -1
  97. package/lib/resolvers/person.js +1 -1
  98. package/lib/resolvers/person.js.map +1 -1
  99. package/lib/resolvers/picture.d.ts +4 -4
  100. package/lib/resolvers/richText.d.ts +1 -1
  101. package/lib/resolvers/teaser.d.ts +1 -1
  102. package/lib/resolvers/topper.d.ts +3 -3
  103. package/package.json +1 -1
  104. package/queries/article.graphql +35 -13
  105. package/src/datasources/capi.test.ts +3 -3
  106. package/src/datasources/capi.ts +5 -3
  107. package/src/fixtures/capiObject.ts +4 -2
  108. package/src/fixtures/capiPerson.ts +1 -1
  109. package/src/generated/index.ts +321 -132
  110. package/src/helpers/decorateHeadshotUrl.ts +2 -2
  111. package/src/model/Byline.test.ts +136 -55
  112. package/src/model/Byline.ts +49 -39
  113. package/src/model/CapiResponse.test.ts +9 -25
  114. package/src/model/CapiResponse.ts +83 -56
  115. package/src/model/Clip.ts +1 -1
  116. package/src/model/Concept.ts +3 -3
  117. package/src/model/FlourishSource.ts +1 -1
  118. package/src/model/Image.test.ts +1 -1
  119. package/src/model/Image.ts +1 -1
  120. package/src/model/LeadFlourish.test.ts +1 -0
  121. package/src/model/Person.test.ts +11 -62
  122. package/src/model/Person.ts +47 -51
  123. package/src/model/Picture.test.ts +1 -1
  124. package/src/model/Picture.ts +1 -1
  125. package/src/model/Topper.test.ts +22 -18
  126. package/src/model/Topper.ts +10 -9
  127. package/src/model/__snapshots__/Byline.test.ts.snap +166 -27
  128. package/src/model/schemas/capi/article.ts +1 -0
  129. package/src/model/schemas/capi/audio.ts +1 -0
  130. package/src/model/schemas/capi/base-schema.ts +5 -4
  131. package/src/model/schemas/capi/content-package.ts +2 -0
  132. package/src/model/schemas/capi/internal-content.ts +45 -0
  133. package/src/model/schemas/capi/live-blog-package.ts +1 -0
  134. package/src/model/schemas/capi/placeholder.ts +1 -0
  135. package/src/model/schemas/capi/video.ts +2 -0
  136. package/src/resolvers/concept.ts +29 -12
  137. package/src/resolvers/content-tree/Workarounds.ts +39 -20
  138. package/src/resolvers/content-tree/references/Author.ts +18 -0
  139. package/src/resolvers/content-tree/references/ClipSet.ts +6 -8
  140. package/src/resolvers/content-tree/references/ImageSet.ts +1 -1
  141. package/src/resolvers/content-tree/references/ScrollyImage.ts +1 -1
  142. package/src/resolvers/content-tree/references/index.ts +6 -1
  143. package/src/resolvers/content-tree/updateTreeWithReferenceIds.ts +6 -8
  144. package/src/resolvers/content.ts +4 -2
  145. package/src/resolvers/leadFlourish.ts +1 -0
  146. package/src/resolvers/person.ts +1 -1
  147. package/src/types/n-display-metadata.d.ts +1 -1
  148. package/tsconfig.tsbuildinfo +1 -1
  149. package/typedefs/clip.graphql +2 -2
  150. package/typedefs/concept.graphql +64 -2
  151. package/typedefs/content.graphql +63 -39
  152. package/typedefs/image.graphql +12 -12
  153. package/typedefs/leadFlourish.graphql +32 -0
  154. package/typedefs/person.graphql +2 -2
  155. package/typedefs/picture.graphql +6 -6
  156. package/typedefs/references/author.graphql +7 -0
  157. package/typedefs/references/clipSet.graphql +14 -2
  158. package/typedefs/references/tweet.graphql +1 -1
  159. package/typedefs/teaser.graphql +10 -10
  160. package/src/types/internal-content.d.ts +0 -55
  161. package/typedefs/leadFlouish.graphql +0 -29
@@ -3,7 +3,7 @@ import type {
3
3
  ContentTypeSchemas,
4
4
  MainImage,
5
5
  ClipSet,
6
- } from '../types/internal-content'
6
+ } from './schemas/capi/internal-content'
7
7
  import conceptIds from '@financial-times/n-concept-ids'
8
8
  import metadata from '@financial-times/n-display-metadata'
9
9
  import cloneDeep from 'clone-deep'
@@ -13,7 +13,7 @@ import type { QueryContext } from '..'
13
13
  import sortBy from 'lodash.sortby'
14
14
 
15
15
  import { CAPIImage } from './Image'
16
- import { Concept } from './Concept'
16
+ import { Concept, predicates } from './Concept'
17
17
  import { Person } from './Person'
18
18
  import isError from '../helpers/isError'
19
19
  import { uuidFromUrl } from '../helpers/metadata'
@@ -30,6 +30,7 @@ import {
30
30
  } from '../resolvers/literal-union'
31
31
 
32
32
  import {
33
+ ContentAnnotationsArgs,
33
34
  ContentPackageContainsArgs,
34
35
  ContentUrlArgs,
35
36
  Media,
@@ -161,18 +162,21 @@ export class CapiResponse {
161
162
  packageContainer
162
163
  )
163
164
  }
165
+
164
166
  body() {
165
167
  if (this.type() === 'Video' && 'transcript' in this.capiData) {
166
168
  return new RichText('transcript', this.capiData.transcript ?? null, this)
167
169
  }
168
170
  return new RichText('bodyXML', this.bodyXML(), this)
169
171
  }
172
+
170
173
  bodyXML(): string | null {
171
174
  if ('bodyXML' in this.capiData) {
172
175
  return this.capiData.bodyXML
173
176
  }
174
177
  return null
175
178
  }
179
+
176
180
  embeds(): (ImageSet | ClipSet)[] {
177
181
  if ('embeds' in this.capiData && this.capiData.embeds)
178
182
  return this.capiData.embeds
@@ -184,41 +188,43 @@ export class CapiResponse {
184
188
  return null
185
189
  }
186
190
 
187
- byline({ vanity }: Partial<ContentUrlArgs>) {
188
- const bylineText = this.rawByline()
189
-
190
- if (!bylineText) return null
191
- const authorUrlMapping = this.getAuthorUrlMapping()
191
+ async byline({ vanity }: Partial<ContentUrlArgs>) {
192
+ if (!this.capiData.byline) return null
192
193
 
193
- return new Byline(
194
- bylineText,
194
+ const byline = new Byline(
195
+ this.capiData.byline,
195
196
  Boolean(vanity),
196
- authorUrlMapping,
197
- this.context
198
- ).buildBylineTree()
199
- }
200
- rawByline(): string | null {
201
- if ('byline' in this.capiData && this.capiData.byline) {
202
- return this.capiData.byline
203
- }
197
+ this.annotations({ byPredicate: predicates.hasAuthor }),
198
+ this
199
+ )
204
200
 
205
- return null
201
+ return byline.buildBylineTree()
206
202
  }
203
+
207
204
  #rawAnnotations() {
208
205
  return this.capiData.annotations
209
206
  }
210
207
 
211
- annotations(): Concept[] {
212
- return sortBy(this.#rawAnnotations(), ['predicate', 'id']).map(
208
+ annotations({ byPredicate }: ContentAnnotationsArgs = {}): Concept[] {
209
+ const concepts = sortBy(this.#rawAnnotations(), ['predicate', 'id']).map(
213
210
  (concept) => new Concept(concept, this.context)
214
211
  )
212
+
213
+ if (byPredicate) {
214
+ return concepts.filter((concept) => concept.predicate() === byPredicate)
215
+ }
216
+
217
+ return concepts
215
218
  }
219
+
216
220
  types() {
217
221
  return this.capiData.types
218
222
  }
223
+
219
224
  url({ relative, vanity }: Partial<ContentUrlArgs>) {
220
225
  return relative ? this.relativeUrl(vanity) : this.absoluteUrl(vanity)
221
226
  }
227
+
222
228
  async absoluteUrl(vanity?: boolean | null) {
223
229
  const url =
224
230
  this.capiData.webUrl ??
@@ -243,43 +249,65 @@ export class CapiResponse {
243
249
  }
244
250
  return url
245
251
  }
252
+
246
253
  leadImages() {
247
254
  if ('leadImages' in this.capiData) return this.capiData.leadImages
248
255
  return []
249
256
  }
257
+
250
258
  topper() {
251
259
  if ('topper' in this.capiData) return this.capiData.topper
252
260
  return null
253
261
  }
254
- authors(): Person[] | null {
255
- const authors = this.getAuthors()
256
- return authors.length === 0
257
- ? null
258
- : authors.map((author: Concept) => new Person(author, this, this.context))
262
+
263
+ async authors(): Promise<Person[]> {
264
+ const authors = this.annotations({ byPredicate: predicates.hasAuthor })
265
+
266
+ return Promise.all(
267
+ authors.map((author) =>
268
+ this.context.dataSources.capi.getPerson(author.uuid())
269
+ )
270
+ )
271
+ }
272
+
273
+ async primaryAuthor(): Promise<Person | null> {
274
+ const primaryAuthor = this.annotations({
275
+ byPredicate: predicates.hasAuthor,
276
+ })[0]
277
+
278
+ if (primaryAuthor) {
279
+ return this.context.dataSources.capi.getPerson(primaryAuthor.uuid())
280
+ }
281
+
282
+ return null
259
283
  }
284
+
260
285
  accessLevel(): LiteralUnionScalarValues<typeof AccessLevel> {
261
286
  return this.capiData.accessLevel ?? 'subscribed'
262
287
  }
288
+
263
289
  editorialDesk() {
264
290
  return this.capiData.editorialDesk ?? null
265
291
  }
292
+
266
293
  canBeSyndicated(): LiteralUnionScalarValues<typeof CanBeSyndicated> {
267
294
  return this.capiData.canBeSyndicated
268
295
  }
296
+
269
297
  instantAlertConcept() {
270
- const authors = this.getAuthors()
271
- const authorConcept = authors.length === 1 ? authors[0] : null
298
+ const authors = this.annotations({ byPredicate: predicates.hasAuthor })
272
299
  const displayConcept = this.getDisplayConcept()
273
300
 
274
- if ((this.isOpinion() || this.isAlphaville()) && authorConcept) {
275
- return authorConcept
301
+ if ((this.isColumn() || this.isAlphaville()) && authors[0]) {
302
+ return authors[0]
276
303
  } else if (displayConcept?.isOrganisation()) {
277
304
  return displayConcept
278
305
  }
279
306
 
280
307
  return null
281
308
  }
282
- originatingParty(): string | null {
309
+
310
+ originatingParty() {
283
311
  return this.capiData.canBeDistributed === 'no' &&
284
312
  this.annotations().some((annotation) => {
285
313
  annotation.id() ===
@@ -288,10 +316,12 @@ export class CapiResponse {
288
316
  ? 'Reuters'
289
317
  : 'FT'
290
318
  }
319
+
291
320
  summary() {
292
321
  if ('summary' in this.capiData) return this.capiData.summary
293
322
  return null
294
323
  }
324
+
295
325
  title() {
296
326
  // CI-2038 HACK to remove "Comment:" prefix from live blog post titles
297
327
  // as this is as the sole indicator that this is an opinion post
@@ -303,9 +333,11 @@ export class CapiResponse {
303
333
  }
304
334
  return this.capiData.title
305
335
  }
336
+
306
337
  id() {
307
338
  return uuidFromUrl(this.capiData.id)
308
339
  }
340
+
309
341
  standfirst(): string | null {
310
342
  if ('standfirst' in this.capiData && this.capiData.standfirst) {
311
343
  return this.capiData.standfirst
@@ -313,18 +345,33 @@ export class CapiResponse {
313
345
 
314
346
  return null
315
347
  }
348
+
316
349
  publishedDate() {
350
+ // WARNING:20240815:RB: "publishedDate" is only updated for editorial
351
+ // publishes where they explictly check a box to update it. If you need
352
+ // all editorial changes you may be after "lastModified" instead.
317
353
  return this.capiData.publishedDate
318
354
  }
355
+
319
356
  publishedTimestamp() {
357
+ // WARNING:20240815:RB: "publishedTimestamp" is only updated for editorial
358
+ // publishes where they explictly check a box to update it. If you need
359
+ // all editorial changes you may be after "modifiedTimestamp" instead.
320
360
  return new Date(this.capiData.publishedDate).getTime()
321
361
  }
362
+
322
363
  firstPublishedDate() {
323
364
  return this.capiData.firstPublishedDate || this.capiData.publishedDate
324
365
  }
366
+
367
+ modifiedTimestamp() {
368
+ return new Date(this.capiData.lastModified).getTime()
369
+ }
370
+
325
371
  publishReference() {
326
- return this.capiData.publishReference
372
+ return this.capiData.publishReference ?? null
327
373
  }
374
+
328
375
  alternativeTitle() {
329
376
  return this.capiData.alternativeTitles ?? null
330
377
  }
@@ -417,7 +464,7 @@ export class CapiResponse {
417
464
 
418
465
  async metaPrefixText() {
419
466
  const meta = await this.#teaserMetadata()
420
- return meta.prefixText
467
+ return meta.prefixText ?? null
421
468
  }
422
469
 
423
470
  teaser() {
@@ -493,23 +540,6 @@ export class CapiResponse {
493
540
  return cloneDeep(this.capiData)
494
541
  }
495
542
 
496
- #createStreamLink(streamUuid: string) {
497
- return `https://www.ft.com/stream/${streamUuid}`
498
- }
499
- getAuthorUrlMapping() {
500
- const authorAnnotations = this.getAuthors()
501
- return authorAnnotations.reduce((mapping, author: Concept) => {
502
- if (author.prefLabel()) {
503
- const nameWithCurlyApostrophes = author.prefLabel().replace("'", '’')
504
- mapping.set(
505
- nameWithCurlyApostrophes,
506
- this.#createStreamLink(author.uuid())
507
- )
508
- }
509
- return mapping
510
- }, new Map<string, string>())
511
- }
512
-
513
543
  getBrandConcept() {
514
544
  return this.annotations().find((annotation: Concept) =>
515
545
  annotation.isBrand()
@@ -546,14 +576,11 @@ export class CapiResponse {
546
576
  )
547
577
  }
548
578
 
549
- getAuthors() {
550
- return this.annotations().filter((annotation: Concept) =>
551
- annotation.isAuthor()
552
- )
553
- }
554
-
555
579
  isColumn() {
556
- return this.isOpinion() && this.getAuthors().length === 1
580
+ return (
581
+ this.isOpinion() &&
582
+ this.annotations({ byPredicate: predicates.hasAuthor }).length === 1
583
+ )
557
584
  }
558
585
 
559
586
  isOpinion() {
package/src/model/Clip.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { uuidFromUrl } from '../helpers/metadata'
2
- import { Clip as ClipMetadata } from '../types/internal-content'
2
+ import { Clip as ClipMetadata } from './schemas/capi/internal-content'
3
3
  import {
4
4
  LiteralUnionScalarValues,
5
5
  validLiteralUnionValue,
@@ -1,4 +1,4 @@
1
- import type { Annotation } from '../types/internal-content'
1
+ import type { Annotation } from './schemas/capi/internal-content'
2
2
  import type { QueryContext } from '..'
3
3
  import { uuidFromUrl } from '../helpers/metadata'
4
4
  import isError from '../helpers/isError'
@@ -6,7 +6,6 @@ import conceptIds from '@financial-times/n-concept-ids'
6
6
 
7
7
  const CAPI_ID_PREFIX = /^https?:\/\/(?:www|api)\.ft\.com\/things?\//
8
8
  const BASE_URL = 'https://www.ft.com/stream/'
9
- const FT_URL = 'https://www.ft.com'
10
9
 
11
10
  type URLArguments = {
12
11
  vanity?: boolean | null
@@ -163,8 +162,9 @@ export class Concept {
163
162
  }
164
163
 
165
164
  if (args?.relative) {
166
- url = url.replace(FT_URL, '')
165
+ return new URL(url).pathname
167
166
  }
167
+
168
168
  return url
169
169
  }
170
170
  }
@@ -37,7 +37,7 @@ export class FlourishSource {
37
37
  }
38
38
 
39
39
  format() {
40
- return 'standard'
40
+ return 'standard' as const
41
41
  }
42
42
 
43
43
  flourishUrl() {
@@ -1,7 +1,7 @@
1
1
  import { CAPIImage } from './Image'
2
2
  import { jest } from '@jest/globals'
3
3
  import type { QueryContext } from '..'
4
- import { Image } from '../types/internal-content'
4
+ import { Image } from './schemas/capi/internal-content'
5
5
  import { OrigamiImageDataSource } from '../datasources/origami-image'
6
6
  import { MAX_IMAGE_WIDTH } from '../helpers/imageService'
7
7
  import type { ImageSource } from '../generated'
@@ -4,7 +4,7 @@ import type { DataSources } from '../datasources'
4
4
  import type {
5
5
  Image as InternalContentImage,
6
6
  LeadImage,
7
- } from '../types/internal-content'
7
+ } from './schemas/capi/internal-content'
8
8
  import type { QueryContext } from '..'
9
9
  import {
10
10
  LiteralUnionScalarValues,
@@ -28,6 +28,7 @@ describe('LeadFlourish', () => {
28
28
  clonedBase.topper = {
29
29
  layout: 'flourish',
30
30
  layoutWidth: 'full-width',
31
+ backgroundColour: 'paper',
31
32
  }
32
33
  const capiResponse = new CapiResponse(clonedBase, context)
33
34
  leadFlourish = new LeadFlourish(capiResponse, context)
@@ -1,14 +1,11 @@
1
1
  import { Person } from './Person'
2
2
  import { jest } from '@jest/globals'
3
3
  import type { QueryContext } from '..'
4
- import { Concept } from './Concept'
5
- import { CapiResponse } from './CapiResponse'
6
- import { baseCapiObject } from '../fixtures/capiObject'
7
4
  import { capiPerson } from '../fixtures/capiPerson'
8
- import cloneDeep from 'clone-deep'
9
5
  import { URLManagementDataSource } from '../datasources/url-management'
10
6
  import { CapiDataSource } from '../datasources/capi'
11
7
  import { Logger } from '@dotcom-reliability-kit/logger'
8
+ import { CapiPerson } from './schemas/capi/internal-content'
12
9
 
13
10
  const vanityMock = jest.fn<URLManagementDataSource['get']>()
14
11
  const getPersonMock = jest.fn<CapiDataSource['getPerson']>()
@@ -22,86 +19,38 @@ const context = {
22
19
  logger: new Logger(),
23
20
  } as unknown as QueryContext
24
21
 
25
- const topic = {
26
- apiUrl: 'http://api.ft.com/things/6b32f2c1-da43-4e19-80b9-8aef4ab640d7',
27
- directType: 'http://www.ft.com/ontology/Topic',
28
- id: 'http://api.ft.com/things/6b32f2c1-da43-4e19-80b9-8aef4ab640d7',
29
- predicate: 'http://www.ft.com/ontology/annotation/about',
30
- prefLabel: 'Technology sector',
31
- type: 'TOPIC',
32
- types: [
33
- 'http://www.ft.com/ontology/core/Thing',
34
- 'http://www.ft.com/ontology/concept/Concept',
35
- 'http://www.ft.com/ontology/Topic',
36
- ],
37
- }
38
-
39
- const author = {
40
- apiUrl: 'http://api.ft.com/people/4076f4fd-723b-4ce5-9934-fb29416554fa',
41
- directType: 'http://www.ft.com/ontology/person/Person',
42
- id: 'http://api.ft.com/things/4076f4fd-723b-4ce5-9934-fb29416554fa',
43
- predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
44
- prefLabel: 'Robert Shrimsley',
45
- type: 'PERSON',
46
- types: [
47
- 'http://www.ft.com/ontology/core/Thing',
48
- 'http://www.ft.com/ontology/concept/Concept',
49
- 'http://www.ft.com/ontology/person/Person',
50
- ],
51
- }
52
22
  describe('Person model', () => {
53
- let clonedBase: typeof baseCapiObject
54
- let capiResponse: CapiResponse
55
- let topicConcept: Concept
56
- let authorConcept: Concept
57
-
58
23
  beforeEach(() => {
59
24
  jest.resetAllMocks()
60
-
61
- clonedBase = cloneDeep(baseCapiObject)
62
- capiResponse = new CapiResponse(clonedBase, context)
63
- topicConcept = new Concept(topic, context)
64
- authorConcept = new Concept(author, context)
65
25
  })
66
26
 
67
- describe('headshot()', () => {
68
- it('returns null if the concept is not for an author', async () => {
69
- const model = new Person(topicConcept, capiResponse, context)
70
- expect(await model.headshot()).toEqual(null)
71
- })
72
-
27
+ describe('headshot', () => {
73
28
  it('returns the headshot for an author', async () => {
74
- getPersonMock.mockResolvedValue(capiPerson)
75
- const model = new Person(authorConcept, capiResponse, context)
76
- expect(await model.headshot()).toEqual(
29
+ const model = new Person(capiPerson as CapiPerson, context)
30
+ expect(await model.headshot({})).toEqual(
77
31
  'https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fd1e00ek4ebabms.cloudfront.net%2Fproduction%2Fuploaded-files%2Ffthead-v1_robert-shrimsley-cc467908-15d6-474d-94d8-171594ceabb9.png?source=image-test&fit=scale-down&quality=highest&width=150&dpr=1'
78
32
  )
79
33
  })
80
34
 
81
35
  it('can resize headshot and provide high resolution version', async () => {
82
- getPersonMock.mockResolvedValue(capiPerson)
83
- const model = new Person(authorConcept, capiResponse, context)
36
+ const model = new Person(capiPerson as CapiPerson, context)
84
37
  expect(await model.headshot({ width: 300, dpr: 2 })).toEqual(
85
38
  'https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fd1e00ek4ebabms.cloudfront.net%2Fproduction%2Fuploaded-files%2Ffthead-v1_robert-shrimsley-cc467908-15d6-474d-94d8-171594ceabb9.png?source=image-test&fit=scale-down&quality=highest&width=300&dpr=2'
86
39
  )
87
40
  })
88
41
 
89
- it('returns null if there is an error getting the person data', async () => {
90
- getPersonMock.mockRejectedValue(new Error('error getting person'))
91
- const model = new Person(authorConcept, capiResponse, context)
92
- expect(await model.headshot({ width: 300, dpr: 2 })).toEqual(null)
93
- })
94
-
95
42
  it("returns null if the author doesn't have a headshot", async () => {
96
- getPersonMock.mockResolvedValue({ ...capiPerson, _imageUrl: undefined })
97
- const model = new Person(authorConcept, capiResponse, context)
98
- expect(await model.headshot()).toEqual(null)
43
+ const model = new Person(
44
+ { ...capiPerson, _imageUrl: undefined } as CapiPerson,
45
+ context
46
+ )
47
+ expect(await model.headshot({})).toEqual(null)
99
48
  })
100
49
  })
101
50
 
102
51
  describe('streamPage()', () => {
103
52
  it('returns the stream URL', () => {
104
- const model = new Person(authorConcept, capiResponse, context)
53
+ const model = new Person(capiPerson as CapiPerson, context)
105
54
  expect(model.streamPage()).toEqual(
106
55
  'https://www.ft.com/stream/4076f4fd-723b-4ce5-9934-fb29416554fa'
107
56
  )
@@ -1,37 +1,54 @@
1
1
  import type { QueryContext } from '..'
2
- import { Concept } from './Concept'
3
- import { CapiResponse } from './CapiResponse'
4
2
  import imageServiceUrl from '../helpers/imageService'
5
- import isError from '../helpers/isError'
6
- import decorateHeadshotUrl, { UUID_REGEX } from '../helpers/decorateHeadshotUrl'
3
+ import decorateHeadshotUrl from '../helpers/decorateHeadshotUrl'
7
4
  import { OperationalError } from '@dotcom-reliability-kit/errors'
8
-
9
- type HeadshotArguments = {
10
- width?: number | null
11
- dpr?: number | null
12
- }
13
-
14
- const predicates = {
15
- hasAuthor: 'http://www.ft.com/ontology/annotation/hasAuthor',
16
- } as const
5
+ import { CapiPerson as CapiPersonSchema } from './schemas/capi/base-schema'
6
+ import { CapiPerson } from './schemas/capi/internal-content'
7
+ import flattenFormattedZodIssues from '../helpers/flatten-formatted-zod-errors'
8
+ import { PersonHeadshotArgs } from '../generated'
9
+ import { uuidFromUrl } from '../helpers/metadata'
17
10
 
18
11
  export class Person {
19
12
  #systemCode: string
20
13
 
21
- constructor(
22
- private concept: Concept,
23
- private capiResponse: CapiResponse,
24
- private context: QueryContext
25
- ) {
14
+ static fromJson(data: unknown, context: QueryContext): Person {
15
+ const result = CapiPersonSchema.safeParse(data)
16
+ if (!result.success) {
17
+ context.logger.warn({
18
+ event: 'RECOVERABLE_ERROR',
19
+ error: new OperationalError({
20
+ message:
21
+ 'The data received from the CAPI data source does not match our data source schema. It is likely that our schema will require updating to handle all possible responses from CAPI.',
22
+ code: 'CAPI_SCHEMA_VALIDATION_FAILURE',
23
+ schemaError: flattenFormattedZodIssues(result.error.format(), data),
24
+ contentId: (data as { id?: string }).id,
25
+ contentType: 'Person',
26
+ }),
27
+ })
28
+ context.metrics?.count(
29
+ `graphql.datasource.CapiDataSource.Person.validation.failure.count`,
30
+ 1
31
+ )
32
+ } else {
33
+ context.metrics?.count(
34
+ `graphql.datasource.CapiDataSource.Person.validation.success.count`,
35
+ 1
36
+ )
37
+ }
38
+
39
+ return new Person(data as CapiPerson, context)
40
+ }
41
+
42
+ constructor(private person: CapiPerson, private context: QueryContext) {
26
43
  this.#systemCode = context.systemCode ?? 'cp-content-pipeline'
27
44
  }
28
45
 
29
46
  prefLabel() {
30
- return this.concept.prefLabel()
47
+ return this.person.prefLabel
31
48
  }
32
49
 
33
50
  uuid() {
34
- return this.concept.uuid()
51
+ return uuidFromUrl(this.person.id)
35
52
  }
36
53
 
37
54
  streamPage() {
@@ -40,40 +57,19 @@ export class Person {
40
57
  }
41
58
 
42
59
  apiUrl() {
43
- return this.concept.apiUrl()
60
+ return this.person.apiUrl
44
61
  }
45
62
 
46
- isAuthor() {
47
- return this.concept.predicate() === predicates.hasAuthor
48
- }
63
+ headshot(args: PersonHeadshotArgs) {
64
+ const url = decorateHeadshotUrl(this.person)
49
65
 
50
- async headshot(args?: HeadshotArguments) {
51
- try {
52
- const uuid = this.apiUrl().match(UUID_REGEX)?.[0]
53
- if (!this.isAuthor() || !uuid || !this.capiResponse) return null
54
- const person = await this.context.dataSources.capi.getPerson(uuid)
55
- const url = decorateHeadshotUrl(person)
56
-
57
- return url
58
- ? imageServiceUrl({
59
- url,
60
- systemCode: this.#systemCode,
61
- width: args?.width || 150,
62
- dpr: args?.dpr ?? undefined,
63
- })
64
- : null
65
- } catch (error) {
66
- if (isError(error)) {
67
- this.context.logger.warn({
68
- event: 'RECOVERABLE_ERROR',
69
- error: new OperationalError({
70
- message: `Error fetching CAPI Person for headshot URL for ${this.prefLabel()}`,
71
- code: 'HEADSHOT_PERSON_FETCH_ERROR',
72
- cause: error,
73
- }),
66
+ return url
67
+ ? imageServiceUrl({
68
+ url,
69
+ systemCode: this.#systemCode,
70
+ width: args.width || 150,
71
+ dpr: args.dpr ?? undefined,
74
72
  })
75
- }
76
- return null
77
- }
73
+ : null
78
74
  }
79
75
  }
@@ -1,5 +1,5 @@
1
1
  import { QueryContext } from '..'
2
- import { ImageSet } from '../types/internal-content'
2
+ import { ImageSet } from './schemas/capi/internal-content'
3
3
  import { Picture } from './Picture'
4
4
 
5
5
  const isLiveBlog = false
@@ -1,5 +1,5 @@
1
1
  import { QueryContext } from '..'
2
- import { ImageSet } from '../types/internal-content'
2
+ import { ImageSet } from './schemas/capi/internal-content'
3
3
  import { CAPIImage, Image } from './Image'
4
4
  import assert from 'assert'
5
5