@financial-times/cp-content-pipeline-schema 2.6.2 → 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 +14 -0
- package/lib/generated/index.d.ts +27 -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-tree/Workarounds.d.ts +4 -1
- package/lib/resolvers/content-tree/nodePredicates.d.ts +1 -1
- package/lib/resolvers/content-tree/references/Recommended.d.ts +1 -0
- package/lib/resolvers/content-tree/references/Recommended.js +5 -0
- package/lib/resolvers/content-tree/references/Recommended.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 +10 -0
- package/src/generated/index.ts +31 -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-tree/Workarounds.ts +5 -1
- package/src/resolvers/content-tree/references/Recommended.ts +8 -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/references/recommended.graphql +3 -0
- package/typedefs/topper.graphql +3 -3
package/package.json
CHANGED
package/queries/article.graphql
CHANGED
|
@@ -75,6 +75,12 @@ fragment Intro on RichText {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
fragment Person on Person {
|
|
79
|
+
headshot(dpr: 2, width: 150)
|
|
80
|
+
prefLabel
|
|
81
|
+
streamPage
|
|
82
|
+
}
|
|
83
|
+
|
|
78
84
|
fragment Topper on Topper {
|
|
79
85
|
__typename
|
|
80
86
|
headline
|
|
@@ -363,6 +369,7 @@ fragment LayoutImage on LayoutImage {
|
|
|
363
369
|
}
|
|
364
370
|
|
|
365
371
|
fragment Recommended on Recommended {
|
|
372
|
+
isInLiveBlog
|
|
366
373
|
teaser {
|
|
367
374
|
...Teaser
|
|
368
375
|
}
|
|
@@ -589,6 +596,9 @@ fragment ArticleFields on Content {
|
|
|
589
596
|
...Content
|
|
590
597
|
url
|
|
591
598
|
... on LiveBlogPost {
|
|
599
|
+
authors {
|
|
600
|
+
...Person
|
|
601
|
+
}
|
|
592
602
|
isPinned
|
|
593
603
|
indicators {
|
|
594
604
|
...Indicators
|
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
|
|
|
@@ -1307,6 +1323,8 @@ export type RawImage = Reference & {
|
|
|
1307
1323
|
};
|
|
1308
1324
|
|
|
1309
1325
|
export type Recommended = Reference & {
|
|
1326
|
+
/** Indicates if its inside a live blog */
|
|
1327
|
+
readonly isInLiveBlog?: Maybe<Scalars['Boolean']['output']>;
|
|
1310
1328
|
/** Recommended references contain teaser thumbnails of related articles. */
|
|
1311
1329
|
readonly teaser?: Maybe<Teaser>;
|
|
1312
1330
|
/** The type of the reference, eg. 'recommended'. */
|
|
@@ -1452,6 +1470,7 @@ export type TopperWithHeadshot = {
|
|
|
1452
1470
|
|
|
1453
1471
|
export type TopperWithHeadshotHeadshotArgs = {
|
|
1454
1472
|
dpr?: InputMaybe<Scalars['Int']['input']>;
|
|
1473
|
+
url?: InputMaybe<Scalars['String']['input']>;
|
|
1455
1474
|
width?: InputMaybe<Scalars['Int']['input']>;
|
|
1456
1475
|
};
|
|
1457
1476
|
|
|
@@ -1695,6 +1714,7 @@ export type ResolversTypes = ResolversObject<{
|
|
|
1695
1714
|
Mutation: ResolverTypeWrapper<{}>;
|
|
1696
1715
|
OpinionTopper: ResolverTypeWrapper<TopperModel>;
|
|
1697
1716
|
PackageDesign: ResolverTypeWrapper<Scalars['PackageDesign']['output']>;
|
|
1717
|
+
Person: ResolverTypeWrapper<PersonModel>;
|
|
1698
1718
|
Picture: ResolverTypeWrapper<PictureModel>;
|
|
1699
1719
|
PictureFullBleed: ResolverTypeWrapper<PictureModel>;
|
|
1700
1720
|
PictureInline: ResolverTypeWrapper<PictureModel>;
|
|
@@ -1781,6 +1801,7 @@ export type ResolversParentTypes = ResolversObject<{
|
|
|
1781
1801
|
Mutation: {};
|
|
1782
1802
|
OpinionTopper: TopperModel;
|
|
1783
1803
|
PackageDesign: Scalars['PackageDesign']['output'];
|
|
1804
|
+
Person: PersonModel;
|
|
1784
1805
|
Picture: PictureModel;
|
|
1785
1806
|
PictureFullBleed: PictureModel;
|
|
1786
1807
|
PictureInline: PictureModel;
|
|
@@ -2339,6 +2360,7 @@ export type LiveBlogPostResolvers<ContextType = QueryContext, ParentType extends
|
|
|
2339
2360
|
altStandfirst: Resolver<Maybe<ResolversTypes['AltStandfirst']>, ParentType, ContextType>;
|
|
2340
2361
|
altTitle: Resolver<Maybe<ResolversTypes['AltTitle']>, ParentType, ContextType>;
|
|
2341
2362
|
annotations: Resolver<Maybe<ReadonlyArray<Maybe<ResolversTypes['Concept']>>>, ParentType, ContextType>;
|
|
2363
|
+
authors: Resolver<Maybe<ReadonlyArray<Maybe<ResolversTypes['Person']>>>, ParentType, ContextType>;
|
|
2342
2364
|
body: Resolver<Maybe<ResolversTypes['RichText']>, ParentType, ContextType>;
|
|
2343
2365
|
bodyXML: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
|
2344
2366
|
byline: Resolver<Maybe<ResolversTypes['StructuredContent']>, ParentType, ContextType, Partial<LiveBlogPostBylineArgs>>;
|
|
@@ -2407,6 +2429,13 @@ export interface PackageDesignScalarConfig extends GraphQLScalarTypeConfig<Resol
|
|
|
2407
2429
|
name: 'PackageDesign';
|
|
2408
2430
|
}
|
|
2409
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
|
+
|
|
2410
2439
|
export type PictureResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['Picture'] = ResolversParentTypes['Picture']> = ResolversObject<{
|
|
2411
2440
|
__resolveType?: TypeResolveFn<'PictureFullBleed' | 'PictureInline' | 'PictureStandard', ParentType, ContextType>;
|
|
2412
2441
|
alt: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
|
@@ -2510,6 +2539,7 @@ export type RawImageResolvers<ContextType = QueryContext, ParentType extends Res
|
|
|
2510
2539
|
}>;
|
|
2511
2540
|
|
|
2512
2541
|
export type RecommendedResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['Recommended'] = ResolversParentTypes['Recommended']> = ResolversObject<{
|
|
2542
|
+
isInLiveBlog: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
|
2513
2543
|
teaser: Resolver<Maybe<ResolversTypes['Teaser']>, ParentType, ContextType>;
|
|
2514
2544
|
type: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
|
2515
2545
|
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
|
@@ -2722,6 +2752,7 @@ export type Resolvers<ContextType = QueryContext> = ResolversObject<{
|
|
|
2722
2752
|
Mutation: MutationResolvers<ContextType>;
|
|
2723
2753
|
OpinionTopper: OpinionTopperResolvers<ContextType>;
|
|
2724
2754
|
PackageDesign: GraphQLScalarType;
|
|
2755
|
+
Person: PersonResolvers<ContextType>;
|
|
2725
2756
|
Picture: PictureResolvers<ContextType>;
|
|
2726
2757
|
PictureFullBleed: PictureFullBleedResolvers<ContextType>;
|
|
2727
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
|
}
|
|
@@ -156,6 +156,10 @@ export interface TableCell extends ContentTree.Parent {
|
|
|
156
156
|
children: ContentTree.Phrasing[]
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
export interface Recommended extends ContentTree.Recommended {
|
|
160
|
+
isInLiveBlog?: boolean
|
|
161
|
+
}
|
|
162
|
+
|
|
159
163
|
export type AnyNode =
|
|
160
164
|
| ContentTree.Root
|
|
161
165
|
| ContentTree.Body
|
|
@@ -175,7 +179,7 @@ export type AnyNode =
|
|
|
175
179
|
| ContentTree.ImageSet
|
|
176
180
|
| ClipSet
|
|
177
181
|
| OldClip
|
|
178
|
-
|
|
|
182
|
+
| Recommended
|
|
179
183
|
| ContentTree.Tweet
|
|
180
184
|
| ContentTree.Flourish
|
|
181
185
|
| ContentTree.BigNumber
|
|
@@ -32,4 +32,12 @@ export const Recommended = {
|
|
|
32
32
|
type(parent) {
|
|
33
33
|
return parent.reference.type
|
|
34
34
|
},
|
|
35
|
+
|
|
36
|
+
isInLiveBlog(parent) {
|
|
37
|
+
return Boolean(
|
|
38
|
+
parent?.contentApiData
|
|
39
|
+
?.types()
|
|
40
|
+
?.includes('http://www.ft.com/ontology/content/LiveBlogPost')
|
|
41
|
+
)
|
|
42
|
+
},
|
|
35
43
|
} satisfies RecommendedResolvers
|
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
|