@financial-times/cp-content-pipeline-schema 2.7.0 → 2.8.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 +7 -0
- package/lib/generated/index.d.ts +24 -0
- package/lib/model/CapiResponse.d.ts +2 -0
- package/lib/model/CapiResponse.js +7 -0
- package/lib/model/CapiResponse.js.map +1 -1
- package/lib/model/Concept.d.ts +0 -2
- package/lib/model/Concept.js +0 -56
- 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/Person.d.ts +21 -0
- package/lib/model/Person.js +101 -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.d.ts +1 -0
- package/lib/resolvers/content.js +1 -0
- package/lib/resolvers/content.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 +1 -1
- package/queries/article.graphql +9 -0
- package/src/generated/index.ts +28 -0
- package/src/model/CapiResponse.ts +7 -0
- package/src/model/Concept.test.ts +0 -49
- package/src/model/Concept.ts +0 -31
- package/src/model/Person.test.ts +110 -0
- package/src/model/Person.ts +74 -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.ts +1 -0
- 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
package/src/generated/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
|
|
2
2
|
import type { Concept as ConceptModel } from '../model/Concept';
|
|
3
|
+
import type { Person as PersonModel } from '../model/Person';
|
|
3
4
|
import type { CapiResponse } from '../model/CapiResponse';
|
|
4
5
|
import type { Image as ImageModel } from '../model/Image';
|
|
5
6
|
import type { Clip as ClipModel } from '../model/Clip';
|
|
@@ -990,6 +991,7 @@ export type LiveBlogPost = Content & {
|
|
|
990
991
|
readonly altTitle?: Maybe<AltTitle>;
|
|
991
992
|
/** An array of concepts related to the article, eg. organisations or topics. */
|
|
992
993
|
readonly annotations?: Maybe<ReadonlyArray<Maybe<Concept>>>;
|
|
994
|
+
readonly authors?: Maybe<ReadonlyArray<Maybe<Person>>>;
|
|
993
995
|
/** An abstract syntax tree of the article content. */
|
|
994
996
|
readonly body?: Maybe<RichText>;
|
|
995
997
|
/** The raw string of the XML as returned from ContentAPI. */
|
|
@@ -1108,6 +1110,19 @@ export type OpinionTopper = Topper & TopperWithHeadshot & TopperWithTheme & {
|
|
|
1108
1110
|
|
|
1109
1111
|
|
|
1110
1112
|
export type OpinionTopperHeadshotArgs = {
|
|
1113
|
+
dpr?: InputMaybe<Scalars['Int']['input']>;
|
|
1114
|
+
url?: InputMaybe<Scalars['String']['input']>;
|
|
1115
|
+
width?: InputMaybe<Scalars['Int']['input']>;
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
export type Person = {
|
|
1119
|
+
readonly headshot?: Maybe<Scalars['String']['output']>;
|
|
1120
|
+
readonly prefLabel?: Maybe<Scalars['String']['output']>;
|
|
1121
|
+
readonly streamPage?: Maybe<Scalars['String']['output']>;
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
export type PersonHeadshotArgs = {
|
|
1111
1126
|
dpr?: InputMaybe<Scalars['Int']['input']>;
|
|
1112
1127
|
width?: InputMaybe<Scalars['Int']['input']>;
|
|
1113
1128
|
};
|
|
@@ -1277,6 +1292,7 @@ export type PodcastTopper = Topper & TopperWithBrand & TopperWithHeadshot & Topp
|
|
|
1277
1292
|
|
|
1278
1293
|
export type PodcastTopperHeadshotArgs = {
|
|
1279
1294
|
dpr?: InputMaybe<Scalars['Int']['input']>;
|
|
1295
|
+
url?: InputMaybe<Scalars['String']['input']>;
|
|
1280
1296
|
width?: InputMaybe<Scalars['Int']['input']>;
|
|
1281
1297
|
};
|
|
1282
1298
|
|
|
@@ -1454,6 +1470,7 @@ export type TopperWithHeadshot = {
|
|
|
1454
1470
|
|
|
1455
1471
|
export type TopperWithHeadshotHeadshotArgs = {
|
|
1456
1472
|
dpr?: InputMaybe<Scalars['Int']['input']>;
|
|
1473
|
+
url?: InputMaybe<Scalars['String']['input']>;
|
|
1457
1474
|
width?: InputMaybe<Scalars['Int']['input']>;
|
|
1458
1475
|
};
|
|
1459
1476
|
|
|
@@ -1697,6 +1714,7 @@ export type ResolversTypes = ResolversObject<{
|
|
|
1697
1714
|
Mutation: ResolverTypeWrapper<{}>;
|
|
1698
1715
|
OpinionTopper: ResolverTypeWrapper<TopperModel>;
|
|
1699
1716
|
PackageDesign: ResolverTypeWrapper<Scalars['PackageDesign']['output']>;
|
|
1717
|
+
Person: ResolverTypeWrapper<PersonModel>;
|
|
1700
1718
|
Picture: ResolverTypeWrapper<PictureModel>;
|
|
1701
1719
|
PictureFullBleed: ResolverTypeWrapper<PictureModel>;
|
|
1702
1720
|
PictureInline: ResolverTypeWrapper<PictureModel>;
|
|
@@ -1783,6 +1801,7 @@ export type ResolversParentTypes = ResolversObject<{
|
|
|
1783
1801
|
Mutation: {};
|
|
1784
1802
|
OpinionTopper: TopperModel;
|
|
1785
1803
|
PackageDesign: Scalars['PackageDesign']['output'];
|
|
1804
|
+
Person: PersonModel;
|
|
1786
1805
|
Picture: PictureModel;
|
|
1787
1806
|
PictureFullBleed: PictureModel;
|
|
1788
1807
|
PictureInline: PictureModel;
|
|
@@ -2341,6 +2360,7 @@ export type LiveBlogPostResolvers<ContextType = QueryContext, ParentType extends
|
|
|
2341
2360
|
altStandfirst: Resolver<Maybe<ResolversTypes['AltStandfirst']>, ParentType, ContextType>;
|
|
2342
2361
|
altTitle: Resolver<Maybe<ResolversTypes['AltTitle']>, ParentType, ContextType>;
|
|
2343
2362
|
annotations: Resolver<Maybe<ReadonlyArray<Maybe<ResolversTypes['Concept']>>>, ParentType, ContextType>;
|
|
2363
|
+
authors: Resolver<Maybe<ReadonlyArray<Maybe<ResolversTypes['Person']>>>, ParentType, ContextType>;
|
|
2344
2364
|
body: Resolver<Maybe<ResolversTypes['RichText']>, ParentType, ContextType>;
|
|
2345
2365
|
bodyXML: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
|
2346
2366
|
byline: Resolver<Maybe<ResolversTypes['StructuredContent']>, ParentType, ContextType, Partial<LiveBlogPostBylineArgs>>;
|
|
@@ -2409,6 +2429,13 @@ export interface PackageDesignScalarConfig extends GraphQLScalarTypeConfig<Resol
|
|
|
2409
2429
|
name: 'PackageDesign';
|
|
2410
2430
|
}
|
|
2411
2431
|
|
|
2432
|
+
export type PersonResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['Person'] = ResolversParentTypes['Person']> = ResolversObject<{
|
|
2433
|
+
headshot: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType, Partial<PersonHeadshotArgs>>;
|
|
2434
|
+
prefLabel: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
|
2435
|
+
streamPage: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
|
2436
|
+
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
|
2437
|
+
}>;
|
|
2438
|
+
|
|
2412
2439
|
export type PictureResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['Picture'] = ResolversParentTypes['Picture']> = ResolversObject<{
|
|
2413
2440
|
__resolveType?: TypeResolveFn<'PictureFullBleed' | 'PictureInline' | 'PictureStandard', ParentType, ContextType>;
|
|
2414
2441
|
alt: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
|
@@ -2725,6 +2752,7 @@ export type Resolvers<ContextType = QueryContext> = ResolversObject<{
|
|
|
2725
2752
|
Mutation: MutationResolvers<ContextType>;
|
|
2726
2753
|
OpinionTopper: OpinionTopperResolvers<ContextType>;
|
|
2727
2754
|
PackageDesign: GraphQLScalarType;
|
|
2755
|
+
Person: PersonResolvers<ContextType>;
|
|
2728
2756
|
Picture: PictureResolvers<ContextType>;
|
|
2729
2757
|
PictureFullBleed: PictureFullBleedResolvers<ContextType>;
|
|
2730
2758
|
PictureInline: PictureInlineResolvers<ContextType>;
|
|
@@ -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'
|
|
@@ -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
|
}
|
|
@@ -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/'
|
|
@@ -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
|
}
|
|
@@ -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,74 @@
|
|
|
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
|
+
|
|
8
|
+
type HeadshotArguments = {
|
|
9
|
+
width?: number | null
|
|
10
|
+
dpr?: number | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const predicates = {
|
|
14
|
+
hasAuthor: 'http://www.ft.com/ontology/annotation/hasAuthor',
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
export class Person {
|
|
18
|
+
#systemCode: string
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private concept: Concept,
|
|
22
|
+
private capiResponse: CapiResponse,
|
|
23
|
+
private context: QueryContext
|
|
24
|
+
) {
|
|
25
|
+
this.#systemCode = context.systemCode ?? 'cp-content-pipeline'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
prefLabel() {
|
|
29
|
+
return this.concept.prefLabel()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
uuid() {
|
|
33
|
+
return this.concept.uuid()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
streamPage() {
|
|
37
|
+
const uuid = this.uuid()
|
|
38
|
+
return `https://www.ft.com/stream/${uuid}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
apiUrl() {
|
|
42
|
+
return this.concept.apiUrl()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
isAuthor() {
|
|
46
|
+
return this.concept.predicate() === predicates.hasAuthor
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async headshot(args?: HeadshotArguments) {
|
|
50
|
+
try {
|
|
51
|
+
const uuid = this.apiUrl().match(UUID_REGEX)?.[0]
|
|
52
|
+
if (!this.isAuthor() || !uuid || !this.capiResponse) return null
|
|
53
|
+
const person = await this.context.dataSources.capi.getPerson(uuid)
|
|
54
|
+
const url = decorateHeadshotUrl(person)
|
|
55
|
+
|
|
56
|
+
return url
|
|
57
|
+
? imageServiceUrl({
|
|
58
|
+
url,
|
|
59
|
+
systemCode: this.#systemCode,
|
|
60
|
+
width: args?.width || 150,
|
|
61
|
+
dpr: args?.dpr ?? undefined,
|
|
62
|
+
})
|
|
63
|
+
: null
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (isError(error)) {
|
|
66
|
+
this.context.logger.error({
|
|
67
|
+
event: 'RECOVERABLE_ERROR',
|
|
68
|
+
error,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
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
|
}
|
package/src/resolvers/content.ts
CHANGED
package/src/resolvers/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { default as richText } from './richText'
|
|
|
9
9
|
import { default as scalars } from './scalars'
|
|
10
10
|
import { default as teaser } from './teaser'
|
|
11
11
|
import { default as topper } from './topper'
|
|
12
|
+
import { default as person } from './person'
|
|
12
13
|
import { resolvers as references } from './content-tree/references'
|
|
13
14
|
import { Resolvers } from '../generated'
|
|
14
15
|
|
|
@@ -25,6 +26,7 @@ const resolvers = {
|
|
|
25
26
|
...scalars,
|
|
26
27
|
...teaser,
|
|
27
28
|
...topper,
|
|
29
|
+
...person,
|
|
28
30
|
} satisfies Resolvers
|
|
29
31
|
|
|
30
32
|
export default resolvers
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PersonResolvers } from '../generated'
|
|
2
|
+
const resolvers = {
|
|
3
|
+
Person: {
|
|
4
|
+
headshot: (parent) => parent.headshot(),
|
|
5
|
+
prefLabel: (parent) => parent.prefLabel(),
|
|
6
|
+
streamPage: (parent) => parent.streamPage(),
|
|
7
|
+
},
|
|
8
|
+
} satisfies {
|
|
9
|
+
Person: PersonResolvers
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default resolvers
|