@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.
- package/CHANGELOG.md +24 -0
- package/lib/datasources/capi.d.ts +1 -1
- package/lib/datasources/capi.js +14 -39
- package/lib/datasources/capi.js.map +1 -1
- package/lib/datasources/instrumented.d.ts +4 -1
- package/lib/datasources/instrumented.js +16 -16
- package/lib/datasources/instrumented.js.map +1 -1
- package/lib/datasources/origami-image.d.ts +1 -1
- package/lib/datasources/origami-image.js +7 -21
- package/lib/datasources/origami-image.js.map +1 -1
- package/lib/datasources/twitter.d.ts +1 -1
- package/lib/datasources/twitter.js +7 -21
- package/lib/datasources/twitter.js.map +1 -1
- package/lib/generated/index.d.ts +24 -0
- package/lib/model/CapiResponse.d.ts +2 -0
- package/lib/model/CapiResponse.js +40 -4
- package/lib/model/CapiResponse.js.map +1 -1
- package/lib/model/Concept.d.ts +0 -2
- package/lib/model/Concept.js +1 -57
- package/lib/model/Concept.js.map +1 -1
- package/lib/model/Concept.test.js +0 -40
- package/lib/model/Concept.test.js.map +1 -1
- package/lib/model/Image.js +8 -3
- package/lib/model/Image.js.map +1 -1
- package/lib/model/Person.d.ts +21 -0
- package/lib/model/Person.js +106 -0
- package/lib/model/Person.js.map +1 -0
- package/lib/model/Person.test.d.ts +1 -0
- package/lib/model/Person.test.js +96 -0
- package/lib/model/Person.test.js.map +1 -0
- package/lib/model/Topper.d.ts +2 -1
- package/lib/model/Topper.js +18 -16
- package/lib/model/Topper.js.map +1 -1
- package/lib/model/Topper.test.js +29 -0
- package/lib/model/Topper.test.js.map +1 -1
- package/lib/model/schemas/capi/base-schema.d.ts +3 -0
- package/lib/model/schemas/capi/base-schema.js +1 -0
- package/lib/model/schemas/capi/base-schema.js.map +1 -1
- package/lib/resolvers/content-tree/bodyXMLToTree.js +1 -1
- package/lib/resolvers/content-tree/bodyXMLToTree.js.map +1 -1
- package/lib/resolvers/content-tree/bodyXMLToTree.test.js +7 -7
- package/lib/resolvers/content-tree/bodyXMLToTree.test.js.map +1 -1
- package/lib/resolvers/content-tree/references/Flourish.js +7 -2
- package/lib/resolvers/content-tree/references/Flourish.js.map +1 -1
- package/lib/resolvers/content-tree/references/RawImage.js +7 -2
- package/lib/resolvers/content-tree/references/RawImage.js.map +1 -1
- package/lib/resolvers/content-tree/references/Recommended.js +1 -1
- package/lib/resolvers/content-tree/references/Recommended.js.map +1 -1
- package/lib/resolvers/content-tree/references/Tweet.js +7 -2
- package/lib/resolvers/content-tree/references/Tweet.js.map +1 -1
- package/lib/resolvers/content-tree/references/Video.js +15 -2
- package/lib/resolvers/content-tree/references/Video.js.map +1 -1
- package/lib/resolvers/content.d.ts +1 -0
- package/lib/resolvers/content.js +1 -0
- package/lib/resolvers/content.js.map +1 -1
- package/lib/resolvers/core.js +16 -1
- package/lib/resolvers/core.js.map +1 -1
- package/lib/resolvers/index.d.ts +9 -3
- package/lib/resolvers/index.js +2 -0
- package/lib/resolvers/index.js.map +1 -1
- package/lib/resolvers/person.d.ts +8 -0
- package/lib/resolvers/person.js +11 -0
- package/lib/resolvers/person.js.map +1 -0
- package/lib/resolvers/topper.d.ts +3 -3
- package/package.json +5 -2
- package/queries/article.graphql +9 -0
- package/src/datasources/capi.ts +16 -44
- package/src/datasources/instrumented.ts +29 -31
- package/src/datasources/origami-image.ts +11 -25
- package/src/datasources/twitter.ts +10 -24
- package/src/generated/index.ts +28 -0
- package/src/model/CapiResponse.ts +51 -6
- package/src/model/Concept.test.ts +0 -49
- package/src/model/Concept.ts +1 -32
- package/src/model/Image.ts +9 -4
- package/src/model/Person.test.ts +110 -0
- package/src/model/Person.ts +79 -0
- package/src/model/Topper.test.ts +37 -0
- package/src/model/Topper.ts +18 -23
- package/src/model/schemas/capi/base-schema.ts +1 -0
- package/src/resolvers/content-tree/bodyXMLToTree.test.ts +7 -7
- package/src/resolvers/content-tree/bodyXMLToTree.ts +1 -1
- package/src/resolvers/content-tree/references/Flourish.ts +7 -2
- package/src/resolvers/content-tree/references/RawImage.ts +7 -2
- package/src/resolvers/content-tree/references/Recommended.ts +1 -1
- package/src/resolvers/content-tree/references/Tweet.ts +7 -2
- package/src/resolvers/content-tree/references/Video.ts +18 -4
- package/src/resolvers/content.ts +1 -0
- package/src/resolvers/core.ts +18 -1
- package/src/resolvers/index.ts +2 -0
- package/src/resolvers/person.ts +12 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/typedefs/content.graphql +1 -0
- package/typedefs/person.graphql +5 -0
- 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.
|
|
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.
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/model/Concept.ts
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/src/model/Image.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|
package/src/model/Topper.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/model/Topper.ts
CHANGED
|
@@ -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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
307
|
-
expect(
|
|
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(
|
|
353
|
-
expect(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|