@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.
Files changed (100) hide show
  1. package/.toolkitrc.yml +12 -0
  2. package/CHANGELOG.md +72 -0
  3. package/jest.config.js +3 -0
  4. package/lib/concept.d.ts +7 -0
  5. package/lib/concept.js +39 -0
  6. package/lib/concept.js.map +1 -0
  7. package/lib/constants/contentTypes.d.ts +2 -0
  8. package/lib/constants/contentTypes.js +3 -0
  9. package/lib/constants/contentTypes.js.map +1 -0
  10. package/lib/content.d.ts +55 -0
  11. package/lib/content.js +133 -0
  12. package/lib/content.js.map +1 -0
  13. package/lib/content.test.d.ts +1 -0
  14. package/lib/content.test.js +149 -0
  15. package/lib/content.test.js.map +1 -0
  16. package/lib/datasources/capi.d.ts +10 -0
  17. package/lib/datasources/capi.js +28 -0
  18. package/lib/datasources/capi.js.map +1 -0
  19. package/lib/datasources/index.d.ts +9 -0
  20. package/lib/datasources/index.js +9 -0
  21. package/lib/datasources/index.js.map +1 -0
  22. package/lib/datasources/origami-image.d.ts +8 -0
  23. package/lib/datasources/origami-image.js +11 -0
  24. package/lib/datasources/origami-image.js.map +1 -0
  25. package/lib/datasources/url-management.d.ts +11 -0
  26. package/lib/datasources/url-management.js +40 -0
  27. package/lib/datasources/url-management.js.map +1 -0
  28. package/lib/datasources/url-management.test.d.ts +1 -0
  29. package/lib/datasources/url-management.test.js +69 -0
  30. package/lib/datasources/url-management.test.js.map +1 -0
  31. package/lib/helpers/byline.d.ts +1 -0
  32. package/lib/helpers/byline.js +5 -0
  33. package/lib/helpers/byline.js.map +1 -0
  34. package/lib/helpers/imageService.d.ts +8 -0
  35. package/lib/helpers/imageService.js +13 -0
  36. package/lib/helpers/imageService.js.map +1 -0
  37. package/lib/helpers/metadata.d.ts +12 -0
  38. package/lib/helpers/metadata.js +60 -0
  39. package/lib/helpers/metadata.js.map +1 -0
  40. package/lib/helpers/syntaxTree.d.ts +23 -0
  41. package/lib/helpers/syntaxTree.js +23 -0
  42. package/lib/helpers/syntaxTree.js.map +1 -0
  43. package/lib/image.d.ts +25 -0
  44. package/lib/image.js +123 -0
  45. package/lib/image.js.map +1 -0
  46. package/lib/image.test.d.ts +1 -0
  47. package/lib/image.test.js +235 -0
  48. package/lib/image.test.js.map +1 -0
  49. package/lib/index.d.ts +8 -0
  50. package/lib/index.js +69 -0
  51. package/lib/index.js.map +1 -0
  52. package/lib/picture.d.ts +22 -0
  53. package/lib/picture.js +80 -0
  54. package/lib/picture.js.map +1 -0
  55. package/lib/richText.d.ts +14 -0
  56. package/lib/richText.js +48 -0
  57. package/lib/richText.js.map +1 -0
  58. package/lib/tags.d.ts +13 -0
  59. package/lib/tags.js +178 -0
  60. package/lib/tags.js.map +1 -0
  61. package/lib/topper.d.ts +7 -0
  62. package/lib/topper.js +196 -0
  63. package/lib/topper.js.map +1 -0
  64. package/lib/unified-plugins/extract-references.d.ts +7 -0
  65. package/lib/unified-plugins/extract-references.js +36 -0
  66. package/lib/unified-plugins/extract-references.js.map +1 -0
  67. package/lib/unified-plugins/map-to-abstract-types.d.ts +4 -0
  68. package/lib/unified-plugins/map-to-abstract-types.js +17 -0
  69. package/lib/unified-plugins/map-to-abstract-types.js.map +1 -0
  70. package/package.json +43 -0
  71. package/src/__snapshots__/content.test.ts.snap +118 -0
  72. package/src/concept.ts +58 -0
  73. package/src/constants/contentTypes.ts +4 -0
  74. package/src/content.test.ts +163 -0
  75. package/src/content.ts +146 -0
  76. package/src/datasources/capi.ts +28 -0
  77. package/src/datasources/index.ts +11 -0
  78. package/src/datasources/origami-image.ts +10 -0
  79. package/src/datasources/url-management.test.ts +92 -0
  80. package/src/datasources/url-management.ts +65 -0
  81. package/src/helpers/byline.ts +4 -0
  82. package/src/helpers/imageService.ts +31 -0
  83. package/src/helpers/metadata.ts +88 -0
  84. package/src/helpers/syntaxTree.ts +26 -0
  85. package/src/image.test.ts +339 -0
  86. package/src/image.ts +154 -0
  87. package/src/index.ts +87 -0
  88. package/src/picture.ts +98 -0
  89. package/src/richText.ts +62 -0
  90. package/src/tags.ts +237 -0
  91. package/src/topper.ts +228 -0
  92. package/src/types/internal-content.d.ts +78 -0
  93. package/src/types/n-concept-ids.d.ts +16 -0
  94. package/src/types/n-display-metadata.d.ts +1 -0
  95. package/src/types/n-url-management-api-read-client.d.ts +11 -0
  96. package/src/types/next-metrics.d.ts +1 -0
  97. package/src/unified-plugins/extract-references.ts +50 -0
  98. package/src/unified-plugins/map-to-abstract-types.ts +21 -0
  99. package/tsconfig.json +10 -0
  100. 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,4 @@
1
+ export const Article = 'http://www.ft.com/ontology/content/Article'
2
+
3
+ export const LiveBlogPackage =
4
+ 'http://www.ft.com/ontology/content/LiveBlogPackage'
@@ -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,4 @@
1
+ export function splitBylineByName(byline: string, names: string[]) {
2
+ const regex = new RegExp(`(${names.join('|')})`, 'ig')
3
+ return byline.split(regex).filter((string) => string !== '')
4
+ }
@@ -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
+ }