@financial-times/cp-content-pipeline-schema 2.7.0 → 2.9.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 (95) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/lib/datasources/capi.d.ts +1 -1
  3. package/lib/datasources/capi.js +14 -39
  4. package/lib/datasources/capi.js.map +1 -1
  5. package/lib/datasources/instrumented.d.ts +4 -1
  6. package/lib/datasources/instrumented.js +16 -16
  7. package/lib/datasources/instrumented.js.map +1 -1
  8. package/lib/datasources/origami-image.d.ts +1 -1
  9. package/lib/datasources/origami-image.js +7 -21
  10. package/lib/datasources/origami-image.js.map +1 -1
  11. package/lib/datasources/twitter.d.ts +1 -1
  12. package/lib/datasources/twitter.js +7 -21
  13. package/lib/datasources/twitter.js.map +1 -1
  14. package/lib/generated/index.d.ts +24 -0
  15. package/lib/model/CapiResponse.d.ts +2 -0
  16. package/lib/model/CapiResponse.js +40 -4
  17. package/lib/model/CapiResponse.js.map +1 -1
  18. package/lib/model/Concept.d.ts +0 -2
  19. package/lib/model/Concept.js +1 -57
  20. package/lib/model/Concept.js.map +1 -1
  21. package/lib/model/Concept.test.js +0 -40
  22. package/lib/model/Concept.test.js.map +1 -1
  23. package/lib/model/Image.js +8 -3
  24. package/lib/model/Image.js.map +1 -1
  25. package/lib/model/Person.d.ts +21 -0
  26. package/lib/model/Person.js +106 -0
  27. package/lib/model/Person.js.map +1 -0
  28. package/lib/model/Person.test.d.ts +1 -0
  29. package/lib/model/Person.test.js +96 -0
  30. package/lib/model/Person.test.js.map +1 -0
  31. package/lib/model/Topper.d.ts +2 -1
  32. package/lib/model/Topper.js +18 -16
  33. package/lib/model/Topper.js.map +1 -1
  34. package/lib/model/Topper.test.js +29 -0
  35. package/lib/model/Topper.test.js.map +1 -1
  36. package/lib/model/schemas/capi/base-schema.d.ts +3 -0
  37. package/lib/model/schemas/capi/base-schema.js +1 -0
  38. package/lib/model/schemas/capi/base-schema.js.map +1 -1
  39. package/lib/resolvers/content-tree/bodyXMLToTree.js +1 -1
  40. package/lib/resolvers/content-tree/bodyXMLToTree.js.map +1 -1
  41. package/lib/resolvers/content-tree/bodyXMLToTree.test.js +7 -7
  42. package/lib/resolvers/content-tree/bodyXMLToTree.test.js.map +1 -1
  43. package/lib/resolvers/content-tree/references/Flourish.js +7 -2
  44. package/lib/resolvers/content-tree/references/Flourish.js.map +1 -1
  45. package/lib/resolvers/content-tree/references/RawImage.js +7 -2
  46. package/lib/resolvers/content-tree/references/RawImage.js.map +1 -1
  47. package/lib/resolvers/content-tree/references/Recommended.js +1 -1
  48. package/lib/resolvers/content-tree/references/Recommended.js.map +1 -1
  49. package/lib/resolvers/content-tree/references/Tweet.js +7 -2
  50. package/lib/resolvers/content-tree/references/Tweet.js.map +1 -1
  51. package/lib/resolvers/content-tree/references/Video.js +15 -2
  52. package/lib/resolvers/content-tree/references/Video.js.map +1 -1
  53. package/lib/resolvers/content.d.ts +1 -0
  54. package/lib/resolvers/content.js +1 -0
  55. package/lib/resolvers/content.js.map +1 -1
  56. package/lib/resolvers/core.js +16 -1
  57. package/lib/resolvers/core.js.map +1 -1
  58. package/lib/resolvers/index.d.ts +9 -3
  59. package/lib/resolvers/index.js +2 -0
  60. package/lib/resolvers/index.js.map +1 -1
  61. package/lib/resolvers/person.d.ts +8 -0
  62. package/lib/resolvers/person.js +11 -0
  63. package/lib/resolvers/person.js.map +1 -0
  64. package/lib/resolvers/topper.d.ts +3 -3
  65. package/package.json +5 -2
  66. package/queries/article.graphql +9 -0
  67. package/src/datasources/capi.ts +16 -44
  68. package/src/datasources/instrumented.ts +29 -31
  69. package/src/datasources/origami-image.ts +11 -25
  70. package/src/datasources/twitter.ts +10 -24
  71. package/src/generated/index.ts +28 -0
  72. package/src/model/CapiResponse.ts +51 -6
  73. package/src/model/Concept.test.ts +0 -49
  74. package/src/model/Concept.ts +1 -32
  75. package/src/model/Image.ts +9 -4
  76. package/src/model/Person.test.ts +110 -0
  77. package/src/model/Person.ts +79 -0
  78. package/src/model/Topper.test.ts +37 -0
  79. package/src/model/Topper.ts +18 -23
  80. package/src/model/schemas/capi/base-schema.ts +1 -0
  81. package/src/resolvers/content-tree/bodyXMLToTree.test.ts +7 -7
  82. package/src/resolvers/content-tree/bodyXMLToTree.ts +1 -1
  83. package/src/resolvers/content-tree/references/Flourish.ts +7 -2
  84. package/src/resolvers/content-tree/references/RawImage.ts +7 -2
  85. package/src/resolvers/content-tree/references/Recommended.ts +1 -1
  86. package/src/resolvers/content-tree/references/Tweet.ts +7 -2
  87. package/src/resolvers/content-tree/references/Video.ts +18 -4
  88. package/src/resolvers/content.ts +1 -0
  89. package/src/resolvers/core.ts +18 -1
  90. package/src/resolvers/index.ts +2 -0
  91. package/src/resolvers/person.ts +12 -0
  92. package/tsconfig.tsbuildinfo +1 -1
  93. package/typedefs/content.graphql +1 -0
  94. package/typedefs/person.graphql +5 -0
  95. package/typedefs/topper.graphql +3 -3
@@ -14,6 +14,7 @@ import sortBy from 'lodash.sortby'
14
14
 
15
15
  import { CAPIImage } from './Image'
16
16
  import { Concept } from './Concept'
17
+ import { Person } from './Person'
17
18
  import isError from '../helpers/isError'
18
19
  import { uuidFromUrl } from '../helpers/metadata'
19
20
  import { schemas } from './schemas/capi'
@@ -187,7 +188,7 @@ export class CapiResponse {
187
188
  * Don't let this failure block this system from continuing to try and handle the request
188
189
  */
189
190
  if (!schemaResponse.success) {
190
- context.logger.error({
191
+ context.logger.warn({
191
192
  event: 'RECOVERABLE_ERROR',
192
193
  error: new OperationalError({
193
194
  message:
@@ -285,7 +286,7 @@ export class CapiResponse {
285
286
  return vanityUrl ?? url
286
287
  } catch (error) {
287
288
  if (isError(error)) {
288
- this.context.logger.error({
289
+ this.context.logger.warn({
289
290
  event: 'RECOVERABLE_ERROR',
290
291
  error,
291
292
  })
@@ -302,6 +303,12 @@ export class CapiResponse {
302
303
  if ('topper' in this.capiData) return this.capiData.topper
303
304
  return null
304
305
  }
306
+ authors(): Person[] | null {
307
+ const authors = this.getAuthors()
308
+ return authors.length === 0
309
+ ? null
310
+ : authors.map((author: Concept) => new Person(author, this, this.context))
311
+ }
305
312
  accessLevel(): LiteralUnionScalarValues<typeof AccessLevel> {
306
313
  return this.capiData.accessLevel ?? 'subscribed'
307
314
  }
@@ -398,9 +405,24 @@ export class CapiResponse {
398
405
  'containedIn' in this.capiData && this.capiData.containedIn?.[0]?.id
399
406
 
400
407
  if (containerId) {
401
- container = await this.context.dataSources.capi.getContent(
402
- uuidFromUrl(containerId)
403
- )
408
+ try {
409
+ container = await this.context.dataSources.capi.getContent(
410
+ uuidFromUrl(containerId)
411
+ )
412
+ } catch (error) {
413
+ if (error instanceof Error) {
414
+ this.context.logger.warn({
415
+ event: 'RECOVERABLE_ERROR',
416
+ error: new OperationalError({
417
+ message: `Failed to fetch package container ${containerId} for content ${this.id()}`,
418
+ code: `PACKAGE_CONTAINEDIN_ERROR`,
419
+ cause: error,
420
+ }),
421
+ })
422
+ }
423
+
424
+ return null
425
+ }
404
426
  this.packageContainer = container
405
427
  }
406
428
  }
@@ -645,11 +667,34 @@ export class CapiResponse {
645
667
 
646
668
  this.context.addSurrogateKeys(contains.map((article) => article.id))
647
669
 
648
- return Promise.all(
670
+ const results = await Promise.allSettled(
649
671
  contains.map(({ id }) =>
650
672
  this.context.dataSources.capi.getContent(uuidFromUrl(id), this)
651
673
  )
652
674
  )
675
+
676
+ const failed = results.filter(
677
+ (result): result is PromiseRejectedResult =>
678
+ result.status === 'rejected'
679
+ )
680
+
681
+ if (failed.length) {
682
+ this.context.logger.warn({
683
+ event: 'RECOVERABLE_ERROR',
684
+ error: new OperationalError({
685
+ code: 'PACKAGE_CONTAINS_ERROR',
686
+ message: `Failed to fetch some of the content contained in the package ${this.id()}`,
687
+ cause: new AggregateError(failed.map((result) => result.reason)),
688
+ }),
689
+ })
690
+ }
691
+
692
+ return results
693
+ .filter(
694
+ (result): result is PromiseFulfilledResult<CapiResponse> =>
695
+ result.status === 'fulfilled'
696
+ )
697
+ .map((result) => result.value)
653
698
  }
654
699
 
655
700
  return null
@@ -1,7 +1,6 @@
1
1
  import { Concept } from './Concept'
2
2
  import { jest } from '@jest/globals'
3
3
  import type { QueryContext } from '..'
4
- import { capiPerson } from '../fixtures/capiPerson'
5
4
  import { URLManagementDataSource } from '../datasources/url-management'
6
5
  import { CapiDataSource } from '../datasources/capi'
7
6
  import { Logger } from '@dotcom-reliability-kit/logger'
@@ -32,19 +31,6 @@ const topic = {
32
31
  ],
33
32
  }
34
33
 
35
- const author = {
36
- apiUrl: 'http://api.ft.com/people/4076f4fd-723b-4ce5-9934-fb29416554fa',
37
- directType: 'http://www.ft.com/ontology/person/Person',
38
- id: 'http://api.ft.com/things/4076f4fd-723b-4ce5-9934-fb29416554fa',
39
- predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
40
- prefLabel: 'Robert Shrimsley',
41
- type: 'PERSON',
42
- types: [
43
- 'http://www.ft.com/ontology/core/Thing',
44
- 'http://www.ft.com/ontology/concept/Concept',
45
- 'http://www.ft.com/ontology/person/Person',
46
- ],
47
- }
48
34
  describe('Concept model', () => {
49
35
  beforeEach(() => {
50
36
  jest.resetAllMocks()
@@ -88,39 +74,4 @@ describe('Concept model', () => {
88
74
  )
89
75
  })
90
76
  })
91
-
92
- describe('headshot()', () => {
93
- it('returns null if the concept is not for an author', async () => {
94
- const model = new Concept(topic, context)
95
- expect(await model.headshot()).toEqual(null)
96
- })
97
-
98
- it('returns the headshot for an author', async () => {
99
- getPersonMock.mockResolvedValue(capiPerson)
100
- const model = new Concept(author, context)
101
- expect(await model.headshot()).toEqual(
102
- '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'
103
- )
104
- })
105
-
106
- it('can resize headshot and provide high resolution version', async () => {
107
- getPersonMock.mockResolvedValue(capiPerson)
108
- const model = new Concept(author, context)
109
- expect(await model.headshot({ width: 300, dpr: 2 })).toEqual(
110
- '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'
111
- )
112
- })
113
-
114
- it('returns null if there is an error getting the person data', async () => {
115
- getPersonMock.mockRejectedValue(new Error('error getting person'))
116
- const model = new Concept(author, context)
117
- expect(await model.headshot({ width: 300, dpr: 2 })).toEqual(null)
118
- })
119
-
120
- it("returns null if the author doesn't have a headshot", async () => {
121
- getPersonMock.mockResolvedValue({ ...capiPerson, _imageUrl: undefined })
122
- const model = new Concept(author, context)
123
- expect(await model.headshot()).toEqual(null)
124
- })
125
- })
126
77
  })
@@ -1,11 +1,8 @@
1
1
  import type { Annotation } from '../types/internal-content'
2
2
  import type { QueryContext } from '..'
3
3
  import { uuidFromUrl } from '../helpers/metadata'
4
- import imageServiceUrl from '../helpers/imageService'
5
4
  import isError from '../helpers/isError'
6
5
  import conceptIds from '@financial-times/n-concept-ids'
7
- import decorateHeadshotUrl, { UUID_REGEX } from '../helpers/decorateHeadshotUrl'
8
- import type { TopperWithHeadshotHeadshotArgs } from '../generated'
9
6
 
10
7
  const CAPI_ID_PREFIX = /^https?:\/\/(?:www|api)\.ft\.com\/things?\//
11
8
  const BASE_URL = 'https://www.ft.com/stream/'
@@ -157,7 +154,7 @@ export class Concept {
157
154
  }
158
155
  } catch (error) {
159
156
  if (isError(error)) {
160
- this.context.logger.error({
157
+ this.context.logger.warn({
161
158
  event: 'RECOVERABLE_ERROR',
162
159
  error,
163
160
  })
@@ -170,32 +167,4 @@ export class Concept {
170
167
  }
171
168
  return url
172
169
  }
173
-
174
- async headshot(args?: TopperWithHeadshotHeadshotArgs) {
175
- const uuid = this.apiUrl().match(UUID_REGEX)?.[0]
176
-
177
- if (!this.isAuthor() || !uuid) return null
178
-
179
- try {
180
- const person = await this.context.dataSources.capi.getPerson(uuid)
181
- const url = decorateHeadshotUrl(person)
182
-
183
- return url
184
- ? imageServiceUrl({
185
- url,
186
- systemCode: this.#systemCode,
187
- width: args?.width || 150,
188
- dpr: args?.dpr ?? undefined,
189
- })
190
- : null
191
- } catch (error) {
192
- if (isError(error)) {
193
- this.context.logger.error({
194
- event: 'RECOVERABLE_ERROR',
195
- error,
196
- })
197
- }
198
- return null
199
- }
200
- }
201
170
  }
@@ -12,7 +12,7 @@ import {
12
12
  } from '../resolvers/literal-union'
13
13
  import { ImageFormat, ImageType } from '../resolvers/scalars'
14
14
  import isError from '../helpers/isError'
15
- import { BaseError } from '@dotcom-reliability-kit/errors'
15
+ import { BaseError, OperationalError } from '@dotcom-reliability-kit/errors'
16
16
  import type { ImageSource } from '../generated'
17
17
 
18
18
  export type ImageSourceArgs = {
@@ -54,7 +54,7 @@ export class CAPIImage implements Image {
54
54
  }
55
55
 
56
56
  // we shouldn't be here. but just in case,
57
- this.context.logger.error({
57
+ this.context.logger.warn({
58
58
  event: 'RECOVERABLE_ERROR',
59
59
  error: new BaseError({
60
60
  code: 'INVALID_IMAGE_TYPE',
@@ -164,9 +164,14 @@ export class CAPIImage implements Image {
164
164
  return imageMetadata
165
165
  } catch (error) {
166
166
  if (isError(error)) {
167
- this.context.logger.error({
167
+ this.context.logger.warn({
168
168
  event: 'RECOVERABLE_ERROR',
169
- error,
169
+ error: new OperationalError({
170
+ code: 'IMAGE_DIMENSIONS_ERROR',
171
+ message: `Failed to get dimensions for ${this.capiImage.binaryUrl}`,
172
+ url: this.capiImage.binaryUrl,
173
+ cause: error,
174
+ }),
170
175
  })
171
176
  }
172
177
  return null
@@ -0,0 +1,110 @@
1
+ import { Person } from './Person'
2
+ import { jest } from '@jest/globals'
3
+ import type { QueryContext } from '..'
4
+ import { Concept } from './Concept'
5
+ import { CapiResponse } from './CapiResponse'
6
+ import { baseCapiObject } from '../fixtures/capiObject'
7
+ import { capiPerson } from '../fixtures/capiPerson'
8
+ import cloneDeep from 'clone-deep'
9
+ import { URLManagementDataSource } from '../datasources/url-management'
10
+ import { CapiDataSource } from '../datasources/capi'
11
+ import { Logger } from '@dotcom-reliability-kit/logger'
12
+
13
+ const vanityMock = jest.fn<URLManagementDataSource['get']>()
14
+ const getPersonMock = jest.fn<CapiDataSource['getPerson']>()
15
+ const context = {
16
+ dataSources: {
17
+ origami: {},
18
+ capi: { getPerson: getPersonMock },
19
+ vanityUrls: { get: vanityMock },
20
+ },
21
+ systemCode: 'image-test',
22
+ logger: new Logger(),
23
+ } as unknown as QueryContext
24
+
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
+ describe('Person model', () => {
53
+ let clonedBase: typeof baseCapiObject
54
+ let capiResponse: CapiResponse
55
+ let topicConcept: Concept
56
+ let authorConcept: Concept
57
+
58
+ beforeEach(() => {
59
+ 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
+ })
66
+
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
+
73
+ 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(
77
+ '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
+ )
79
+ })
80
+
81
+ it('can resize headshot and provide high resolution version', async () => {
82
+ getPersonMock.mockResolvedValue(capiPerson)
83
+ const model = new Person(authorConcept, capiResponse, context)
84
+ expect(await model.headshot({ width: 300, dpr: 2 })).toEqual(
85
+ '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
+ )
87
+ })
88
+
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
+ 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)
99
+ })
100
+ })
101
+
102
+ describe('streamPage()', () => {
103
+ it('returns the stream URL', () => {
104
+ const model = new Person(authorConcept, capiResponse, context)
105
+ expect(model.streamPage()).toEqual(
106
+ 'https://www.ft.com/stream/4076f4fd-723b-4ce5-9934-fb29416554fa'
107
+ )
108
+ })
109
+ })
110
+ })
@@ -0,0 +1,79 @@
1
+ import type { QueryContext } from '..'
2
+ import { Concept } from './Concept'
3
+ import { CapiResponse } from './CapiResponse'
4
+ import imageServiceUrl from '../helpers/imageService'
5
+ import isError from '../helpers/isError'
6
+ import decorateHeadshotUrl, { UUID_REGEX } from '../helpers/decorateHeadshotUrl'
7
+ 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
17
+
18
+ export class Person {
19
+ #systemCode: string
20
+
21
+ constructor(
22
+ private concept: Concept,
23
+ private capiResponse: CapiResponse,
24
+ private context: QueryContext
25
+ ) {
26
+ this.#systemCode = context.systemCode ?? 'cp-content-pipeline'
27
+ }
28
+
29
+ prefLabel() {
30
+ return this.concept.prefLabel()
31
+ }
32
+
33
+ uuid() {
34
+ return this.concept.uuid()
35
+ }
36
+
37
+ streamPage() {
38
+ const uuid = this.uuid()
39
+ return `https://www.ft.com/stream/${uuid}`
40
+ }
41
+
42
+ apiUrl() {
43
+ return this.concept.apiUrl()
44
+ }
45
+
46
+ isAuthor() {
47
+ return this.concept.predicate() === predicates.hasAuthor
48
+ }
49
+
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
+ }),
74
+ })
75
+ }
76
+ return null
77
+ }
78
+ }
79
+ }
@@ -1,4 +1,5 @@
1
1
  import { Topper } from './Topper'
2
+ import { Person } from './Person'
2
3
  import { baseCapiObject } from '../fixtures/capiObject'
3
4
  import { CapiResponse } from './CapiResponse'
4
5
  import { predicates } from './Concept'
@@ -426,3 +427,39 @@ describe('produces the correct metadata tags', () => {
426
427
  expect(brandConcept?.prefLabel()).toEqual('Lex')
427
428
  })
428
429
  })
430
+
431
+ describe('headshot method', () => {
432
+ let topper: Topper
433
+ let capiResponse: CapiResponse
434
+
435
+ beforeEach(() => {
436
+ const clonedBase = cloneDeep(baseCapiObject)
437
+ capiResponse = new CapiResponse(clonedBase, context)
438
+ topper = new Topper(capiResponse, context)
439
+ })
440
+
441
+ afterEach(() => {
442
+ jest.clearAllMocks()
443
+ })
444
+
445
+ it('returns null if the content is not opinion genre', () => {
446
+ jest.spyOn(capiResponse, 'isPodcast').mockReturnValue(false)
447
+ jest.spyOn(capiResponse, 'isOpinion').mockReturnValue(false)
448
+
449
+ const result = topper.headshot({})
450
+
451
+ expect(result).toBeNull()
452
+ })
453
+
454
+ it('attempts to return the author headshot if the content is opinion genre', async () => {
455
+ jest.spyOn(capiResponse, 'isPodcast').mockReturnValue(false)
456
+ jest.spyOn(capiResponse, 'isOpinion').mockReturnValue(true)
457
+ jest.spyOn(capiResponse, 'getAuthors').mockReturnValue([])
458
+ jest
459
+ .spyOn(Person.prototype, 'headshot')
460
+ .mockReturnValue(Promise.resolve(null))
461
+
462
+ await topper.headshot({})
463
+ expect(Person.prototype.headshot).toHaveBeenCalledTimes(1)
464
+ })
465
+ })
@@ -1,4 +1,5 @@
1
1
  import { CapiResponse } from './CapiResponse'
2
+ import { Person } from './Person'
2
3
  import { CAPIImage } from './Image'
3
4
  import type { QueryContext } from '..'
4
5
  import {
@@ -299,31 +300,25 @@ export class Topper {
299
300
  return this.capiResponse.design()
300
301
  }
301
302
 
302
- async headshot(args: TopperWithHeadshotHeadshotArgs) {
303
- let headshotUrl: string | undefined
303
+ imageService(args: TopperWithHeadshotHeadshotArgs) {
304
+ if (args.url)
305
+ return imageServiceUrl({
306
+ url: args.url,
307
+ systemCode: this.#systemCode,
308
+ width: args.width ?? undefined,
309
+ dpr: args.dpr ?? undefined,
310
+ })
311
+ return null
312
+ }
304
313
 
314
+ headshot(args: TopperWithHeadshotHeadshotArgs) {
305
315
  if (this.capiResponse.isPodcast()) {
306
- headshotUrl = this.capiResponse.mainImage()?.url()
307
- }
308
-
309
- if (this.capiResponse.isOpinion()) {
310
- const authors = this.capiResponse.getAuthors()
311
- const headshotUrls: (string | null)[] = await Promise.all(
312
- authors.map(async (author) => author.headshot(args))
313
- ).then((res) => res.filter(Boolean))
314
-
315
- headshotUrl = headshotUrls[0] ?? undefined
316
+ args.url = this.capiResponse.mainImage()?.url()
317
+ return this.imageService(args)
316
318
  }
317
-
318
- if (!headshotUrl) {
319
- return null
320
- }
321
-
322
- return imageServiceUrl({
323
- url: headshotUrl,
324
- systemCode: this.#systemCode,
325
- width: args.width ?? undefined,
326
- dpr: args.dpr ?? undefined,
327
- })
319
+ if (!this.capiResponse.isOpinion()) return null
320
+ const author = this.capiResponse.getAuthors()[0]
321
+ const person = new Person(author, this.capiResponse, this.context)
322
+ return person.headshot(args)
328
323
  }
329
324
  }
@@ -162,6 +162,7 @@ export const CapiPerson = z.object({
162
162
  emailAddress: z.string().optional(),
163
163
  twitterHandle: z.string().optional(),
164
164
  descriptionXML: z.string().optional(),
165
+ headshot: z.string().optional(),
165
166
  _imageUrl: z.string().optional(),
166
167
  })
167
168
 
@@ -6,7 +6,7 @@ import { QueryContext, BodyXMLToTreeError } from '../..'
6
6
  import { OperationalError } from '@dotcom-reliability-kit/errors'
7
7
 
8
8
  const mockLogger = new Logger()
9
- const mockLogError = jest.spyOn(mockLogger, 'error')
9
+ const mockLogWarn = jest.spyOn(mockLogger, 'warn')
10
10
 
11
11
  const mockContext = {
12
12
  logger: mockLogger,
@@ -233,7 +233,7 @@ describe('bodyXMLToTree', () => {
233
233
  "version": 1,
234
234
  }
235
235
  `)
236
- expect(mockLogError).not.toBeCalled()
236
+ expect(mockLogWarn).not.toBeCalled()
237
237
  })
238
238
 
239
239
  it('should handle heading and slots', () => {
@@ -268,7 +268,7 @@ describe('bodyXMLToTree', () => {
268
268
  "version": 1,
269
269
  }
270
270
  `)
271
- expect(mockLogError).not.toBeCalled()
271
+ expect(mockLogWarn).not.toBeCalled()
272
272
  })
273
273
 
274
274
  it('should log an error on unexpected child after heading', () => {
@@ -303,8 +303,8 @@ describe('bodyXMLToTree', () => {
303
303
  "version": 1,
304
304
  }
305
305
  `)
306
- expect(mockLogError).toBeCalled()
307
- expect(mockLogError.mock.lastCall).toMatchInlineSnapshot(`
306
+ expect(mockLogWarn).toBeCalled()
307
+ expect(mockLogWarn.mock.lastCall).toMatchInlineSnapshot(`
308
308
  Array [
309
309
  Object {
310
310
  "error": Object {
@@ -349,8 +349,8 @@ describe('bodyXMLToTree', () => {
349
349
  "version": 1,
350
350
  }
351
351
  `)
352
- expect(mockLogError).toBeCalled()
353
- expect(mockLogError.mock.lastCall).toMatchInlineSnapshot(`
352
+ expect(mockLogWarn).toBeCalled()
353
+ expect(mockLogWarn.mock.lastCall).toMatchInlineSnapshot(`
354
354
  Array [
355
355
  Object {
356
356
  "error": Object {
@@ -80,7 +80,7 @@ export default function bodyXMLToTree(
80
80
  function logErrors(context?: QueryContext) {
81
81
  if (!context?.aggregatedErrors?.bodyXMLToTree.length) return
82
82
 
83
- context.logger.error({
83
+ context.logger.warn({
84
84
  event: 'RECOVERABLE_ERROR',
85
85
  message: 'Unexpected structure in bodyXMLToTree',
86
86
  error: new OperationalError({
@@ -5,6 +5,7 @@ import {
5
5
  } from '../../../generated'
6
6
  import { QueryContext } from '../../..'
7
7
  import isError from '../../../helpers/isError'
8
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
8
9
 
9
10
  export const Flourish = {
10
11
  async fallbackImage(parent, _args, context) {
@@ -79,9 +80,13 @@ const getImageMetadata = async (
79
80
  }
80
81
  } catch (error) {
81
82
  if (isError(error)) {
82
- context.logger.error({
83
+ context.logger.warn({
83
84
  event: 'RECOVERABLE_ERROR',
84
- error,
85
+ error: new OperationalError({
86
+ code: 'FLOURISH_IMAGE_METADATA_ERROR',
87
+ message: `Error getting image dimensions for Flourish fallback image ${flourishUrl}`,
88
+ cause: error,
89
+ }),
85
90
  })
86
91
  }
87
92
  return {
@@ -4,6 +4,7 @@ import imageServiceUrl from '../../../helpers/imageService'
4
4
  import { RawImage as RawImageNode } from '../Workarounds'
5
5
  import { RawImageResolvers } from '../../../generated'
6
6
  import isError from '../../../helpers/isError'
7
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
7
8
 
8
9
  class RawImageModel implements Image {
9
10
  constructor(private rawImage: RawImageNode, private context: QueryContext) {}
@@ -50,9 +51,13 @@ class RawImageModel implements Image {
50
51
  return imageMetadata
51
52
  } catch (error) {
52
53
  if (isError(error)) {
53
- this.context.logger.error({
54
+ this.context.logger.warn({
54
55
  event: 'RECOVERABLE_ERROR',
55
- error,
56
+ error: new OperationalError({
57
+ code: 'RAW_IMAGE_DIMENSIONS_ERROR',
58
+ message: `Error getting dimensions for raw image ${this.url()}`,
59
+ cause: error,
60
+ }),
56
61
  })
57
62
  }
58
63
  return null
@@ -15,7 +15,7 @@ export const Recommended = {
15
15
  return content
16
16
  } catch (error) {
17
17
  if (error instanceof Error) {
18
- context.logger.error({
18
+ context.logger.warn({
19
19
  event: 'RECOVERABLE_ERROR',
20
20
  error: new OperationalError({
21
21
  code: 'RECOMMENDED_TEASER_ERROR',