@financial-times/cp-content-pipeline-schema 0.2.1
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/.toolkitrc.yml +12 -0
- package/CHANGELOG.md +72 -0
- package/jest.config.js +3 -0
- package/lib/concept.d.ts +7 -0
- package/lib/concept.js +39 -0
- package/lib/concept.js.map +1 -0
- package/lib/constants/contentTypes.d.ts +2 -0
- package/lib/constants/contentTypes.js +3 -0
- package/lib/constants/contentTypes.js.map +1 -0
- package/lib/content.d.ts +55 -0
- package/lib/content.js +133 -0
- package/lib/content.js.map +1 -0
- package/lib/content.test.d.ts +1 -0
- package/lib/content.test.js +149 -0
- package/lib/content.test.js.map +1 -0
- package/lib/datasources/capi.d.ts +10 -0
- package/lib/datasources/capi.js +28 -0
- package/lib/datasources/capi.js.map +1 -0
- package/lib/datasources/index.d.ts +9 -0
- package/lib/datasources/index.js +9 -0
- package/lib/datasources/index.js.map +1 -0
- package/lib/datasources/origami-image.d.ts +8 -0
- package/lib/datasources/origami-image.js +11 -0
- package/lib/datasources/origami-image.js.map +1 -0
- package/lib/datasources/url-management.d.ts +11 -0
- package/lib/datasources/url-management.js +40 -0
- package/lib/datasources/url-management.js.map +1 -0
- package/lib/datasources/url-management.test.d.ts +1 -0
- package/lib/datasources/url-management.test.js +69 -0
- package/lib/datasources/url-management.test.js.map +1 -0
- package/lib/helpers/byline.d.ts +1 -0
- package/lib/helpers/byline.js +5 -0
- package/lib/helpers/byline.js.map +1 -0
- package/lib/helpers/imageService.d.ts +8 -0
- package/lib/helpers/imageService.js +13 -0
- package/lib/helpers/imageService.js.map +1 -0
- package/lib/helpers/metadata.d.ts +12 -0
- package/lib/helpers/metadata.js +60 -0
- package/lib/helpers/metadata.js.map +1 -0
- package/lib/helpers/syntaxTree.d.ts +23 -0
- package/lib/helpers/syntaxTree.js +23 -0
- package/lib/helpers/syntaxTree.js.map +1 -0
- package/lib/image.d.ts +25 -0
- package/lib/image.js +123 -0
- package/lib/image.js.map +1 -0
- package/lib/image.test.d.ts +1 -0
- package/lib/image.test.js +235 -0
- package/lib/image.test.js.map +1 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +69 -0
- package/lib/index.js.map +1 -0
- package/lib/picture.d.ts +22 -0
- package/lib/picture.js +80 -0
- package/lib/picture.js.map +1 -0
- package/lib/richText.d.ts +14 -0
- package/lib/richText.js +48 -0
- package/lib/richText.js.map +1 -0
- package/lib/tags.d.ts +13 -0
- package/lib/tags.js +178 -0
- package/lib/tags.js.map +1 -0
- package/lib/topper.d.ts +7 -0
- package/lib/topper.js +196 -0
- package/lib/topper.js.map +1 -0
- package/lib/unified-plugins/extract-references.d.ts +7 -0
- package/lib/unified-plugins/extract-references.js +36 -0
- package/lib/unified-plugins/extract-references.js.map +1 -0
- package/lib/unified-plugins/map-to-abstract-types.d.ts +4 -0
- package/lib/unified-plugins/map-to-abstract-types.js +17 -0
- package/lib/unified-plugins/map-to-abstract-types.js.map +1 -0
- package/package.json +43 -0
- package/src/__snapshots__/content.test.ts.snap +118 -0
- package/src/concept.ts +58 -0
- package/src/constants/contentTypes.ts +4 -0
- package/src/content.test.ts +163 -0
- package/src/content.ts +146 -0
- package/src/datasources/capi.ts +28 -0
- package/src/datasources/index.ts +11 -0
- package/src/datasources/origami-image.ts +10 -0
- package/src/datasources/url-management.test.ts +92 -0
- package/src/datasources/url-management.ts +65 -0
- package/src/helpers/byline.ts +4 -0
- package/src/helpers/imageService.ts +31 -0
- package/src/helpers/metadata.ts +88 -0
- package/src/helpers/syntaxTree.ts +26 -0
- package/src/image.test.ts +339 -0
- package/src/image.ts +154 -0
- package/src/index.ts +87 -0
- package/src/picture.ts +98 -0
- package/src/richText.ts +62 -0
- package/src/tags.ts +237 -0
- package/src/topper.ts +228 -0
- package/src/types/internal-content.d.ts +78 -0
- package/src/types/n-concept-ids.d.ts +16 -0
- package/src/types/n-display-metadata.d.ts +1 -0
- package/src/types/n-url-management-api-read-client.d.ts +11 -0
- package/src/types/next-metrics.d.ts +1 -0
- package/src/unified-plugins/extract-references.ts +50 -0
- package/src/unified-plugins/map-to-abstract-types.ts +21 -0
- package/tsconfig.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/concept.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { IResolvers } from '@graphql-tools/utils'
|
|
2
|
+
import { gql } from 'graphql-tag'
|
|
3
|
+
import { DataSources } from './index.js'
|
|
4
|
+
|
|
5
|
+
const CAPI_ID_PREFIX = /^https?:\/\/(?:www|api)\.ft\.com\/things?\//
|
|
6
|
+
const BASE_URL = 'https://www.ft.com/stream/'
|
|
7
|
+
const FT_URL = 'https://www.ft.com'
|
|
8
|
+
|
|
9
|
+
type URLArguments = {
|
|
10
|
+
vanity: boolean
|
|
11
|
+
relative: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const typeDef = gql`
|
|
15
|
+
type Concept {
|
|
16
|
+
apiUrl: String!
|
|
17
|
+
directType: String
|
|
18
|
+
id: String!
|
|
19
|
+
predicate: String!
|
|
20
|
+
prefLabel: String!
|
|
21
|
+
type: String!
|
|
22
|
+
types: [String!]!
|
|
23
|
+
url(vanity: Boolean, relative: Boolean): String!
|
|
24
|
+
}
|
|
25
|
+
`
|
|
26
|
+
|
|
27
|
+
export const resolvers: IResolvers<
|
|
28
|
+
unknown,
|
|
29
|
+
{ systemCode: string; dataSources: DataSources },
|
|
30
|
+
never
|
|
31
|
+
> = {
|
|
32
|
+
Concept: {
|
|
33
|
+
async url(
|
|
34
|
+
parent: { id: string },
|
|
35
|
+
args: URLArguments = { vanity: false, relative: false },
|
|
36
|
+
context
|
|
37
|
+
): Promise<string> {
|
|
38
|
+
let url = parent.id.replace(CAPI_ID_PREFIX, BASE_URL)
|
|
39
|
+
|
|
40
|
+
if (args.vanity) {
|
|
41
|
+
try {
|
|
42
|
+
const vanity: string | null =
|
|
43
|
+
await context.dataSources.vanityUrls.get(url)
|
|
44
|
+
if (vanity !== null) {
|
|
45
|
+
url = vanity
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
//TODO:AG:26-09-2022 log error here, but fail gracefully to use non-vanity
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (args.relative) {
|
|
53
|
+
url = url.replace(FT_URL, '')
|
|
54
|
+
}
|
|
55
|
+
return url
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
import { typeDefs, resolvers } from './index.js'
|
|
4
|
+
|
|
5
|
+
import { ApolloServer } from 'apollo-server'
|
|
6
|
+
import { CapiDataSource } from './datasources/capi.js'
|
|
7
|
+
|
|
8
|
+
const capiDataSource = new CapiDataSource()
|
|
9
|
+
jest.spyOn(capiDataSource, 'getContent').mockImplementation((uuid: string) => {
|
|
10
|
+
return Promise.resolve({
|
|
11
|
+
id: uuid,
|
|
12
|
+
byline: 'Chris Giles in London',
|
|
13
|
+
annotations: [
|
|
14
|
+
{
|
|
15
|
+
id: 'http://api.ft.com/things/1d556016-ad16-4fe7-8724-42b3fb15ad28',
|
|
16
|
+
predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
|
|
17
|
+
prefLabel: 'Chris Giles',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('byline transformation', () => {
|
|
24
|
+
let capiDataSource: CapiDataSource
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
capiDataSource = new CapiDataSource()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const query = `
|
|
31
|
+
query TestQuery($uuid: String!) {
|
|
32
|
+
content(uuid: $uuid) {
|
|
33
|
+
byline {
|
|
34
|
+
tree
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
const testServer = new ApolloServer({
|
|
41
|
+
typeDefs,
|
|
42
|
+
resolvers,
|
|
43
|
+
dataSources: () => {
|
|
44
|
+
return {
|
|
45
|
+
capi: capiDataSource,
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('adds a link around a single known author', async () => {
|
|
51
|
+
jest
|
|
52
|
+
.spyOn(capiDataSource, 'getContent')
|
|
53
|
+
.mockImplementation((uuid: string) => {
|
|
54
|
+
return Promise.resolve({
|
|
55
|
+
id: uuid,
|
|
56
|
+
byline: 'Chris Giles in London',
|
|
57
|
+
annotations: [
|
|
58
|
+
{
|
|
59
|
+
id: 'http://api.ft.com/things/1d556016-ad16-4fe7-8724-42b3fb15ad28',
|
|
60
|
+
predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
|
|
61
|
+
prefLabel: 'Chris Giles',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const result = await testServer.executeOperation({
|
|
68
|
+
query,
|
|
69
|
+
variables: { uuid: '33d1ac64-88ce-4313-8763-da55200ccf43' },
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const got = result?.data?.content.byline.tree
|
|
73
|
+
expect(got).toMatchSnapshot()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('adds links around multiple known authors', async () => {
|
|
77
|
+
jest
|
|
78
|
+
.spyOn(capiDataSource, 'getContent')
|
|
79
|
+
.mockImplementation((uuid: string) => {
|
|
80
|
+
return Promise.resolve({
|
|
81
|
+
id: uuid,
|
|
82
|
+
byline: 'Chris Giles and Martin Wolf in London',
|
|
83
|
+
annotations: [
|
|
84
|
+
{
|
|
85
|
+
id: 'http://api.ft.com/things/1d556016-ad16-4fe7-8724-42b3fb15ad28',
|
|
86
|
+
predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
|
|
87
|
+
prefLabel: 'Chris Giles',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'http://api.ft.com/things/7c1e1e72-57ae-4461-862a-f8d24dd42e22',
|
|
91
|
+
predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
|
|
92
|
+
prefLabel: 'Martin Wolf',
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const result = await testServer.executeOperation({
|
|
99
|
+
query,
|
|
100
|
+
variables: { uuid: '33d1ac64-88ce-4313-8763-da55200ccf43' },
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const got = result?.data?.content.byline.tree
|
|
104
|
+
expect(got).toMatchSnapshot()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('ignores unknown authors in byline text', async () => {
|
|
108
|
+
jest
|
|
109
|
+
.spyOn(capiDataSource, 'getContent')
|
|
110
|
+
.mockImplementation((uuid: string) => {
|
|
111
|
+
return Promise.resolve({
|
|
112
|
+
id: uuid,
|
|
113
|
+
byline: 'Chris Giles and Nick Ramsbottom in London',
|
|
114
|
+
annotations: [
|
|
115
|
+
{
|
|
116
|
+
id: 'http://api.ft.com/things/1d556016-ad16-4fe7-8724-42b3fb15ad28',
|
|
117
|
+
predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
|
|
118
|
+
prefLabel: 'Chris Giles',
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const result = await testServer.executeOperation({
|
|
125
|
+
query,
|
|
126
|
+
variables: { uuid: '33d1ac64-88ce-4313-8763-da55200ccf43' },
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const got = result?.data?.content.byline.tree
|
|
130
|
+
expect(got).toMatchSnapshot()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('ignores extra authors in annotations array', async () => {
|
|
134
|
+
jest
|
|
135
|
+
.spyOn(capiDataSource, 'getContent')
|
|
136
|
+
.mockImplementation((uuid: string) => {
|
|
137
|
+
return Promise.resolve({
|
|
138
|
+
id: uuid,
|
|
139
|
+
byline: 'Martin Wolf in London',
|
|
140
|
+
annotations: [
|
|
141
|
+
{
|
|
142
|
+
id: 'http://api.ft.com/things/1d556016-ad16-4fe7-8724-42b3fb15ad28',
|
|
143
|
+
predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
|
|
144
|
+
prefLabel: 'Chris Giles',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'http://api.ft.com/things/7c1e1e72-57ae-4461-862a-f8d24dd42e22',
|
|
148
|
+
predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
|
|
149
|
+
prefLabel: 'Martin Wolf',
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const result = await testServer.executeOperation({
|
|
156
|
+
query,
|
|
157
|
+
variables: { uuid: '33d1ac64-88ce-4313-8763-da55200ccf43' },
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const got = result?.data?.content.byline.tree
|
|
161
|
+
expect(got).toMatchSnapshot()
|
|
162
|
+
})
|
|
163
|
+
})
|
package/src/content.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { gql } from 'graphql-tag'
|
|
2
|
+
|
|
3
|
+
import metadata from '@financial-times/n-display-metadata'
|
|
4
|
+
import {
|
|
5
|
+
getAuthorUrlMapping,
|
|
6
|
+
isColumn,
|
|
7
|
+
isEditorsChoice,
|
|
8
|
+
isExclusive,
|
|
9
|
+
isOpinion,
|
|
10
|
+
isPodcast,
|
|
11
|
+
isScoop,
|
|
12
|
+
} from './helpers/metadata.js'
|
|
13
|
+
|
|
14
|
+
import { buildUrlTree } from './helpers/syntaxTree.js'
|
|
15
|
+
|
|
16
|
+
import type { ImageSet, InternalContent } from './types/internal-content.js'
|
|
17
|
+
import { splitBylineByName } from './helpers/byline.js'
|
|
18
|
+
|
|
19
|
+
export const typeDef = [
|
|
20
|
+
gql`
|
|
21
|
+
type Content {
|
|
22
|
+
title: String!
|
|
23
|
+
id: String!
|
|
24
|
+
type: String!
|
|
25
|
+
standfirst: String
|
|
26
|
+
topper: Topper
|
|
27
|
+
body: RichText!
|
|
28
|
+
bodyXML: String!
|
|
29
|
+
url(relative: Boolean): String!
|
|
30
|
+
publishedDate: String!
|
|
31
|
+
firstPublishedDate: String!
|
|
32
|
+
metaLink: Concept
|
|
33
|
+
metaAltLink: Concept
|
|
34
|
+
metaPrefixText: String
|
|
35
|
+
metaSuffixText: String
|
|
36
|
+
mainImage: Image
|
|
37
|
+
indicators: Indicators!
|
|
38
|
+
altTitle: AltTitle
|
|
39
|
+
altStandfirst: AltStandfirst
|
|
40
|
+
byline: StructuredContent!
|
|
41
|
+
}
|
|
42
|
+
type Indicators {
|
|
43
|
+
accessLevel: AccessLevel
|
|
44
|
+
isOpinion: Boolean
|
|
45
|
+
isColumn: Boolean
|
|
46
|
+
isPodcast: Boolean
|
|
47
|
+
isEditorsChoice: Boolean
|
|
48
|
+
isExclusive: Boolean
|
|
49
|
+
isScoop: Boolean
|
|
50
|
+
}
|
|
51
|
+
enum AccessLevel {
|
|
52
|
+
premium
|
|
53
|
+
subscribed
|
|
54
|
+
registered
|
|
55
|
+
free
|
|
56
|
+
}
|
|
57
|
+
type AltTitle {
|
|
58
|
+
promotionalTitle: String
|
|
59
|
+
}
|
|
60
|
+
type AltStandfirst {
|
|
61
|
+
promotionalStandfirst: String
|
|
62
|
+
}
|
|
63
|
+
`,
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
function getTeaserMetadata(annotations: object[], field: string) {
|
|
67
|
+
const meta = metadata.teaser({
|
|
68
|
+
annotations,
|
|
69
|
+
})
|
|
70
|
+
return meta[field]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const resolvers = {
|
|
74
|
+
Content: {
|
|
75
|
+
body(parent: InternalContent) {
|
|
76
|
+
return {
|
|
77
|
+
source: 'bodyXML',
|
|
78
|
+
value: parent.bodyXML,
|
|
79
|
+
contentApiData: parent,
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
topper(parent: InternalContent) {
|
|
83
|
+
return parent
|
|
84
|
+
},
|
|
85
|
+
byline(parent: InternalContent) {
|
|
86
|
+
const { annotations } = parent
|
|
87
|
+
const authorUrlMapping = getAuthorUrlMapping(annotations)
|
|
88
|
+
const split = splitBylineByName(parent.byline, [
|
|
89
|
+
...authorUrlMapping.keys(),
|
|
90
|
+
])
|
|
91
|
+
return buildUrlTree(authorUrlMapping, split)
|
|
92
|
+
},
|
|
93
|
+
metaLink(parent: InternalContent) {
|
|
94
|
+
return getTeaserMetadata(parent.annotations, 'link')
|
|
95
|
+
},
|
|
96
|
+
metaAltLink(parent: InternalContent) {
|
|
97
|
+
return getTeaserMetadata(parent.annotations, 'altLink')
|
|
98
|
+
},
|
|
99
|
+
metaPrefixText(parent: InternalContent) {
|
|
100
|
+
return getTeaserMetadata(parent.annotations, 'prefixText')
|
|
101
|
+
},
|
|
102
|
+
metaSuffixText(parent: InternalContent) {
|
|
103
|
+
return getTeaserMetadata(parent.annotations, 'suffixText')
|
|
104
|
+
},
|
|
105
|
+
url(parent: InternalContent, args: { relative: boolean }) {
|
|
106
|
+
const RELATIVE_URL_REGEX = /https?:\/\/www.ft.com/
|
|
107
|
+
if (args.relative) {
|
|
108
|
+
return parent.webUrl.replace(RELATIVE_URL_REGEX, '')
|
|
109
|
+
} else {
|
|
110
|
+
return parent.webUrl
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
type(parent: InternalContent) {
|
|
114
|
+
const TYPE_REGEX = /https?:\/\/www.ft.com\/ontology\/content\//
|
|
115
|
+
return parent.types[0].replace(TYPE_REGEX, '').toLowerCase()
|
|
116
|
+
},
|
|
117
|
+
mainImage(parent: InternalContent) {
|
|
118
|
+
if (
|
|
119
|
+
parent.mainImage?.type === 'http://www.ft.com/ontology/content/Image'
|
|
120
|
+
) {
|
|
121
|
+
return parent.mainImage
|
|
122
|
+
} else if (
|
|
123
|
+
parent.mainImage?.type === 'http://www.ft.com/ontology/content/ImageSet'
|
|
124
|
+
) {
|
|
125
|
+
return (parent.mainImage as ImageSet).members[0]
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
altTitle(parent: InternalContent) {
|
|
129
|
+
return parent.alternativeTitles?.promotionalTitle
|
|
130
|
+
},
|
|
131
|
+
altStandfirst(parent: InternalContent) {
|
|
132
|
+
return parent.alternativeStandfirsts?.promotionalStandfirst
|
|
133
|
+
},
|
|
134
|
+
indicators(parent: InternalContent) {
|
|
135
|
+
return {
|
|
136
|
+
accessLevel: parent.accessLevel,
|
|
137
|
+
isOpinion: isOpinion(parent),
|
|
138
|
+
isColumn: isColumn(parent),
|
|
139
|
+
isPodcast: isPodcast(parent),
|
|
140
|
+
isEditorsChoice: isEditorsChoice(parent),
|
|
141
|
+
isExclusive: isExclusive(parent),
|
|
142
|
+
isScoop: isScoop(parent),
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { RESTDataSource, RequestOptions } from 'apollo-datasource-rest'
|
|
2
|
+
|
|
3
|
+
export class CapiDataSource extends RESTDataSource {
|
|
4
|
+
baseURL = process.env.CAPI_URL || 'https://api-t.ft.com/'
|
|
5
|
+
capiKey = process.env.CAPI_API_KEY || ''
|
|
6
|
+
articleCacheTTL = process.env.ARTICLE_CACHE_TTL
|
|
7
|
+
? parseInt(process.env.ARTICLE_CACHE_TTL)
|
|
8
|
+
: 60 // 60 seconds
|
|
9
|
+
peopleCacheTTL = process.env.PEOPLE_CACHE_TTL
|
|
10
|
+
? parseInt(process.env.PEOPLE_CACHE_TTL)
|
|
11
|
+
: 600 // 10 minutes
|
|
12
|
+
|
|
13
|
+
willSendRequest(request: RequestOptions) {
|
|
14
|
+
request.headers.set('x-api-key', this.capiKey)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async getContent(uuid: string) {
|
|
18
|
+
return this.get(`internalcontent/${uuid}`, undefined, {
|
|
19
|
+
cacheOptions: { ttl: this.articleCacheTTL },
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getPerson(uuid: string) {
|
|
24
|
+
return this.get(`people/${uuid}`, undefined, {
|
|
25
|
+
cacheOptions: { ttl: this.peopleCacheTTL },
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CapiDataSource } from './capi.js'
|
|
2
|
+
import { OrigamiImageDataSource } from './origami-image.js'
|
|
3
|
+
import { URLManagementDataSource } from './url-management.js'
|
|
4
|
+
|
|
5
|
+
export const dataSources = () => ({
|
|
6
|
+
capi: new CapiDataSource(),
|
|
7
|
+
origami: new OrigamiImageDataSource(),
|
|
8
|
+
vanityUrls: new URLManagementDataSource(),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export type DataSources = ReturnType<typeof dataSources>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { RESTDataSource } from 'apollo-datasource-rest'
|
|
2
|
+
|
|
3
|
+
export class OrigamiImageDataSource extends RESTDataSource {
|
|
4
|
+
baseURL = 'https://www.ft.com/__origami/service/image/v2'
|
|
5
|
+
async getImageMetadata(
|
|
6
|
+
url: string
|
|
7
|
+
): Promise<{ width: number; height: number }> {
|
|
8
|
+
return this.get(`/images/metadata/${encodeURIComponent(url)}?source=next`)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// HACK:AG:26-09-2022 hack for jest to support non-ESM module
|
|
2
|
+
// see https://github.com/facebook/jest/issues/10025
|
|
3
|
+
|
|
4
|
+
jest.unstable_mockModule(
|
|
5
|
+
'@financial-times/n-url-management-api-read-client',
|
|
6
|
+
() => ({
|
|
7
|
+
get: jest.fn(),
|
|
8
|
+
init: jest.fn(),
|
|
9
|
+
})
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const urlmgmtimport = await import('./url-management.js')
|
|
13
|
+
|
|
14
|
+
const URLManagementDataSource = urlmgmtimport.URLManagementDataSource
|
|
15
|
+
const nUrlManagementApiReadClient = await import(
|
|
16
|
+
'@financial-times/n-url-management-api-read-client'
|
|
17
|
+
)
|
|
18
|
+
import { InMemoryLRUCache } from 'apollo-server-caching'
|
|
19
|
+
import { jest } from '@jest/globals'
|
|
20
|
+
|
|
21
|
+
const getMock = nUrlManagementApiReadClient.get as jest.Mock
|
|
22
|
+
|
|
23
|
+
describe('URL management data source', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.resetAllMocks()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('only initialises the client library once', async () => {
|
|
29
|
+
new URLManagementDataSource()
|
|
30
|
+
expect(nUrlManagementApiReadClient.init).toHaveBeenCalledTimes(1)
|
|
31
|
+
new URLManagementDataSource()
|
|
32
|
+
expect(nUrlManagementApiReadClient.init).toHaveBeenCalledTimes(1)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('gets a vanity URL from the dynamodb database', async () => {
|
|
36
|
+
getMock.mockResolvedValue({
|
|
37
|
+
toURL: 'vanity.url',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const dataSource = new URLManagementDataSource()
|
|
41
|
+
dataSource.initialize({ context: null, cache: new InMemoryLRUCache() })
|
|
42
|
+
const vanity = await dataSource.get('original.url')
|
|
43
|
+
expect(vanity).toEqual('vanity.url')
|
|
44
|
+
expect(nUrlManagementApiReadClient.get).toHaveBeenCalled()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('gets subsequent requests from the cache', async () => {
|
|
48
|
+
getMock.mockImplementation((fromUrl) =>
|
|
49
|
+
Promise.resolve({ toURL: `${fromUrl}.vanity` })
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const dataSource = new URLManagementDataSource()
|
|
53
|
+
const cache = new InMemoryLRUCache()
|
|
54
|
+
dataSource.initialize({ context: null, cache })
|
|
55
|
+
const firstRequest = await dataSource.get('original.url')
|
|
56
|
+
expect(firstRequest).toEqual('original.url.vanity')
|
|
57
|
+
expect(await cache.getTotalSize()).toEqual(19) // string length of original.url.vanity
|
|
58
|
+
const secondRequest = await dataSource.get('original.url')
|
|
59
|
+
expect(secondRequest).toEqual('original.url.vanity')
|
|
60
|
+
expect(nUrlManagementApiReadClient.get).toHaveBeenCalledTimes(1)
|
|
61
|
+
expect(await cache.getTotalSize()).toEqual(19)
|
|
62
|
+
|
|
63
|
+
const differentRequest = await dataSource.get('different.url')
|
|
64
|
+
expect(differentRequest).toEqual('different.url.vanity')
|
|
65
|
+
expect(nUrlManagementApiReadClient.get).toHaveBeenCalledTimes(2)
|
|
66
|
+
expect(await cache.getTotalSize()).toEqual(39)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('memoizes requests made in the same query', async () => {
|
|
70
|
+
getMock.mockImplementation((fromUrl) =>
|
|
71
|
+
Promise.resolve({ toURL: `${fromUrl}.vanity` })
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const dataSource = new URLManagementDataSource()
|
|
75
|
+
const cache = new InMemoryLRUCache()
|
|
76
|
+
dataSource.initialize({ context: null, cache })
|
|
77
|
+
const firstRequest = dataSource.get('original.url')
|
|
78
|
+
const secondRequest = dataSource.get('original.url')
|
|
79
|
+
const differentRequest = dataSource.get('different.url')
|
|
80
|
+
|
|
81
|
+
const [first, second, different] = await Promise.all([
|
|
82
|
+
firstRequest,
|
|
83
|
+
secondRequest,
|
|
84
|
+
differentRequest,
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
expect(first).toEqual('original.url.vanity')
|
|
88
|
+
expect(second).toEqual('original.url.vanity')
|
|
89
|
+
expect(different).toEqual('different.url.vanity')
|
|
90
|
+
expect(nUrlManagementApiReadClient.get).toHaveBeenCalledTimes(2)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DataSource, DataSourceConfig } from 'apollo-datasource'
|
|
2
|
+
import {
|
|
3
|
+
KeyValueCache,
|
|
4
|
+
InMemoryLRUCache,
|
|
5
|
+
PrefixingKeyValueCache,
|
|
6
|
+
} from 'apollo-server-caching'
|
|
7
|
+
|
|
8
|
+
import * as nUrlManagementApiReadClient from '@financial-times/n-url-management-api-read-client'
|
|
9
|
+
import type { ResolvedVanityURL } from '@financial-times/n-url-management-api-read-client'
|
|
10
|
+
import metrics from 'next-metrics'
|
|
11
|
+
|
|
12
|
+
const CACHE_PREFIX = 'nurlmgmtapi'
|
|
13
|
+
const DEFAULT_TTL_IN_SECONDS = 60 * 60 // one hour
|
|
14
|
+
|
|
15
|
+
export class URLManagementDataSource extends DataSource {
|
|
16
|
+
context: unknown
|
|
17
|
+
cache!: KeyValueCache
|
|
18
|
+
memoizedResults = new Map<string, Promise<string>>()
|
|
19
|
+
|
|
20
|
+
static clientInitialised = false
|
|
21
|
+
constructor() {
|
|
22
|
+
super()
|
|
23
|
+
|
|
24
|
+
if (!URLManagementDataSource.clientInitialised) {
|
|
25
|
+
nUrlManagementApiReadClient.init({ metrics })
|
|
26
|
+
URLManagementDataSource.clientInitialised = true
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override initialize({
|
|
31
|
+
context,
|
|
32
|
+
cache = new InMemoryLRUCache(),
|
|
33
|
+
}: DataSourceConfig<unknown>): void {
|
|
34
|
+
this.context = context
|
|
35
|
+
this.cache = new PrefixingKeyValueCache(cache, CACHE_PREFIX)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async get(
|
|
39
|
+
fromURL: string,
|
|
40
|
+
ttl: number = DEFAULT_TTL_IN_SECONDS
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const getVanity = async (): Promise<string> => {
|
|
43
|
+
const fromCache = await this.cache.get(fromURL)
|
|
44
|
+
if (fromCache) {
|
|
45
|
+
return fromCache
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fromDynamoDB: ResolvedVanityURL =
|
|
49
|
+
await nUrlManagementApiReadClient.get(fromURL)
|
|
50
|
+
await this.cache.set(fromURL, fromDynamoDB.toURL, { ttl })
|
|
51
|
+
return fromDynamoDB.toURL
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let promise = this.memoizedResults.get(fromURL)
|
|
55
|
+
|
|
56
|
+
if (promise) {
|
|
57
|
+
return promise
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
promise = getVanity()
|
|
61
|
+
this.memoizedResults.set(fromURL, promise)
|
|
62
|
+
|
|
63
|
+
return promise
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const DEFAULT_IMAGE_SERVICE_WIDTH = 700
|
|
2
|
+
const DEFAULT_IMAGE_SERVICE_DPR = 1
|
|
3
|
+
|
|
4
|
+
export type ImageServiceUrlArguments = {
|
|
5
|
+
url: string
|
|
6
|
+
systemCode: string
|
|
7
|
+
width?: number
|
|
8
|
+
id?: string
|
|
9
|
+
dpr?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function imageServiceUrl({
|
|
13
|
+
url,
|
|
14
|
+
id,
|
|
15
|
+
systemCode,
|
|
16
|
+
width = DEFAULT_IMAGE_SERVICE_WIDTH,
|
|
17
|
+
dpr = DEFAULT_IMAGE_SERVICE_DPR,
|
|
18
|
+
}: ImageServiceUrlArguments): string {
|
|
19
|
+
const imageSource = encodeURIComponent(id ? `ftcms:${id}` : url)
|
|
20
|
+
const imageServiceUrl = new URL(
|
|
21
|
+
`https://www.ft.com/__origami/service/image/v2/images/raw/${imageSource}`
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
imageServiceUrl.searchParams.set('source', systemCode)
|
|
25
|
+
imageServiceUrl.searchParams.set('fit', 'scale-down')
|
|
26
|
+
imageServiceUrl.searchParams.set('quality', 'highest')
|
|
27
|
+
imageServiceUrl.searchParams.set('width', width.toString())
|
|
28
|
+
imageServiceUrl.searchParams.set('dpr', dpr.toString())
|
|
29
|
+
|
|
30
|
+
return imageServiceUrl.href
|
|
31
|
+
}
|