@financial-times/cp-content-pipeline-schema 2.4.1 → 2.5.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 +20 -0
- package/lib/datasources/capi.d.ts +2 -4
- package/lib/datasources/capi.js +8 -12
- package/lib/datasources/capi.js.map +1 -1
- package/lib/datasources/instrumented.d.ts +5 -5
- package/lib/datasources/instrumented.js +36 -27
- package/lib/datasources/instrumented.js.map +1 -1
- package/lib/datasources/origami-image.d.ts +2 -3
- package/lib/datasources/origami-image.js +3 -3
- package/lib/datasources/origami-image.js.map +1 -1
- package/lib/datasources/twitter.d.ts +2 -2
- package/lib/datasources/twitter.js +3 -3
- package/lib/datasources/twitter.js.map +1 -1
- package/lib/fixtures/dummyContext.js +6 -5
- package/lib/fixtures/dummyContext.js.map +1 -1
- package/lib/model/CapiResponse.d.ts +1 -50
- package/lib/model/CapiResponse.js +43 -20
- package/lib/model/CapiResponse.js.map +1 -1
- package/lib/resolvers/content-tree/bodyXMLToTree.d.ts +1 -1
- package/lib/resolvers/content-tree/tagMappings.d.ts +2 -1
- package/lib/resolvers/content-tree/tagMappings.js +1 -1
- package/lib/resolvers/content-tree/tagMappings.js.map +1 -1
- package/lib/resolvers/content-tree/tagMappings.test.js +9 -2
- package/lib/resolvers/content-tree/tagMappings.test.js.map +1 -1
- package/lib/types/cache.d.ts +1 -1
- package/lib/types/cache.js +4 -1
- package/lib/types/cache.js.map +1 -1
- package/package.json +2 -2
- package/src/datasources/capi.ts +9 -15
- package/src/datasources/instrumented.ts +62 -43
- package/src/datasources/origami-image.ts +5 -9
- package/src/datasources/twitter.ts +5 -11
- package/src/fixtures/dummyContext.ts +7 -5
- package/src/model/CapiResponse.ts +68 -29
- package/src/resolvers/content-tree/bodyXMLToTree.ts +1 -1
- package/src/resolvers/content-tree/tagMappings.test.ts +18 -3
- package/src/resolvers/content-tree/tagMappings.ts +6 -2
- package/src/types/cache.ts +6 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -30,6 +30,15 @@ import {
|
|
|
30
30
|
|
|
31
31
|
import { ContentPackageContainsArgs, Media } from '../generated'
|
|
32
32
|
import { Topper } from './Topper'
|
|
33
|
+
import { z } from 'zod'
|
|
34
|
+
|
|
35
|
+
function isPlainObject(object: unknown): object is Record<string, unknown> {
|
|
36
|
+
if (object && typeof object === 'object') {
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
33
42
|
|
|
34
43
|
type Design = {
|
|
35
44
|
theme: LiteralUnionScalarValues<typeof PackageDesign>
|
|
@@ -56,7 +65,7 @@ type ZodInputObject = {
|
|
|
56
65
|
|
|
57
66
|
function flattenFormattedZodIssues(
|
|
58
67
|
issues: FormattedZodIssues,
|
|
59
|
-
object:
|
|
68
|
+
object: unknown,
|
|
60
69
|
path: string[] = [],
|
|
61
70
|
flattened: string[] = []
|
|
62
71
|
): string[] {
|
|
@@ -94,7 +103,7 @@ function flattenFormattedZodIssues(
|
|
|
94
103
|
key !== '_errors' &&
|
|
95
104
|
!Array.isArray(value) &&
|
|
96
105
|
object &&
|
|
97
|
-
(Array.isArray(object) ||
|
|
106
|
+
(Array.isArray(object) || isPlainObject(object))
|
|
98
107
|
) {
|
|
99
108
|
const isArrayKey = !Number.isNaN(parseInt(key))
|
|
100
109
|
flattenFormattedZodIssues(
|
|
@@ -109,6 +118,36 @@ function flattenFormattedZodIssues(
|
|
|
109
118
|
return flattened
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
function getContentType(
|
|
122
|
+
content: { type?: string; types: string[] },
|
|
123
|
+
validate?: true
|
|
124
|
+
): LiteralUnionScalarValues<typeof ContentType>
|
|
125
|
+
function getContentType(
|
|
126
|
+
content: { type?: string; types: string[] },
|
|
127
|
+
validate: false
|
|
128
|
+
): LiteralUnionScalarValues<typeof ContentType> | string
|
|
129
|
+
function getContentType(
|
|
130
|
+
content: { type?: string; types: string[] },
|
|
131
|
+
validate = true
|
|
132
|
+
) {
|
|
133
|
+
const TYPE_REGEX = /https?:\/\/www.ft.com\/ontology\/content\//
|
|
134
|
+
const useType =
|
|
135
|
+
'type' in content && content.type ? content.type : content.types[0]
|
|
136
|
+
const type = useType.replace(TYPE_REGEX, '')
|
|
137
|
+
|
|
138
|
+
if (validate && !validLiteralUnionValue(type, ContentType.values)) {
|
|
139
|
+
throw new Error('Content type is invalid: ' + useType)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return type
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const baselineContentSchema = z.object({
|
|
146
|
+
id: z.string(),
|
|
147
|
+
type: z.string().optional(),
|
|
148
|
+
types: z.array(z.string()),
|
|
149
|
+
})
|
|
150
|
+
|
|
112
151
|
export class CapiResponse {
|
|
113
152
|
constructor(
|
|
114
153
|
private capiData: ContentTypeSchemas,
|
|
@@ -117,11 +156,15 @@ export class CapiResponse {
|
|
|
117
156
|
) {}
|
|
118
157
|
|
|
119
158
|
static fromJSON(
|
|
120
|
-
content:
|
|
159
|
+
content: unknown,
|
|
121
160
|
context: QueryContext,
|
|
122
161
|
packageContainer?: CapiResponse
|
|
123
162
|
): CapiResponse {
|
|
124
|
-
|
|
163
|
+
// if the content doesn't meet the baseline schema, something's gone
|
|
164
|
+
// unexpectedly very wrong. throwing an non-operational error is the
|
|
165
|
+
// right thing to do in that case, so we call .parse not .safeParse
|
|
166
|
+
const baseContent = baselineContentSchema.parse(content)
|
|
167
|
+
const type = getContentType(baseContent)
|
|
125
168
|
|
|
126
169
|
/*
|
|
127
170
|
* Check if the incoming data matches the types defined in our data source schema
|
|
@@ -129,7 +172,8 @@ export class CapiResponse {
|
|
|
129
172
|
* As there is no agreed schema provided by CAPI there is a chance it will be out of date / incorrect at times
|
|
130
173
|
* Manual updates will be required at times to keep it in sync with the responses we receive
|
|
131
174
|
*/
|
|
132
|
-
const schemaResponse =
|
|
175
|
+
const schemaResponse = schemas(type).safeParse(content)
|
|
176
|
+
|
|
133
177
|
/*
|
|
134
178
|
* Log an error if the response fails validation
|
|
135
179
|
* Logs should be used to warn us of issues or response changes
|
|
@@ -146,32 +190,31 @@ export class CapiResponse {
|
|
|
146
190
|
schemaResponse.error.format(),
|
|
147
191
|
content
|
|
148
192
|
),
|
|
149
|
-
contentId:
|
|
150
|
-
contentType:
|
|
193
|
+
contentId: baseContent.id,
|
|
194
|
+
contentType: type,
|
|
151
195
|
}),
|
|
152
196
|
})
|
|
153
197
|
|
|
154
198
|
context.metrics?.count(
|
|
155
|
-
`graphql.datasource.CapiDataSource.${
|
|
156
|
-
false
|
|
157
|
-
)}.validation.failure.count`,
|
|
199
|
+
`graphql.datasource.CapiDataSource.${type}.validation.failure.count`,
|
|
158
200
|
1
|
|
159
201
|
)
|
|
160
202
|
} else {
|
|
161
203
|
context.metrics?.count(
|
|
162
|
-
`graphql.datasource.CapiDataSource.${
|
|
163
|
-
false
|
|
164
|
-
)}.validation.success.count`,
|
|
204
|
+
`graphql.datasource.CapiDataSource.${type}.validation.success.count`,
|
|
165
205
|
1
|
|
166
206
|
)
|
|
167
207
|
}
|
|
168
208
|
|
|
169
|
-
return
|
|
209
|
+
// we can "safely" cast content to the schema return types here. if the type is
|
|
210
|
+
// incorrect, we've at least logged that error.
|
|
211
|
+
return new CapiResponse(
|
|
212
|
+
content as ContentTypeSchemas,
|
|
213
|
+
context,
|
|
214
|
+
packageContainer
|
|
215
|
+
)
|
|
170
216
|
}
|
|
171
217
|
|
|
172
|
-
schema() {
|
|
173
|
-
return schemas(this.type())
|
|
174
|
-
}
|
|
175
218
|
bodyXML(): string | null {
|
|
176
219
|
if ('bodyXML' in this.capiData) {
|
|
177
220
|
return this.capiData.bodyXML
|
|
@@ -281,7 +324,10 @@ export class CapiResponse {
|
|
|
281
324
|
return this.capiData.title
|
|
282
325
|
}
|
|
283
326
|
id() {
|
|
284
|
-
|
|
327
|
+
const uuid = uuidFromUrl(this.capiData.id)
|
|
328
|
+
this.context.addSurrogateKeys([uuid])
|
|
329
|
+
|
|
330
|
+
return uuid
|
|
285
331
|
}
|
|
286
332
|
standfirst(): string | null {
|
|
287
333
|
if ('standfirst' in this.capiData && this.capiData.standfirst) {
|
|
@@ -410,18 +456,11 @@ export class CapiResponse {
|
|
|
410
456
|
type(validate?: true): LiteralUnionScalarValues<typeof ContentType>
|
|
411
457
|
type(validate: false): LiteralUnionScalarValues<typeof ContentType> | string
|
|
412
458
|
type(validate = true) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
: this.capiData.types[0]
|
|
418
|
-
const type = useType.replace(TYPE_REGEX, '')
|
|
419
|
-
|
|
420
|
-
if (validate && !validLiteralUnionValue(type, ContentType.values)) {
|
|
421
|
-
throw new Error('Content type is invalid: ' + useType)
|
|
459
|
+
if (validate) {
|
|
460
|
+
return getContentType(this.capiData, true)
|
|
461
|
+
} else {
|
|
462
|
+
return getContentType(this.capiData, false)
|
|
422
463
|
}
|
|
423
|
-
|
|
424
|
-
return type
|
|
425
464
|
}
|
|
426
465
|
|
|
427
466
|
topperHasImage() {
|
|
@@ -10,7 +10,7 @@ function isNode(node: ContentTree.Node | undefined): node is ContentTree.Node {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
type ContentTreeTransform = (
|
|
13
|
-
$el: cheerio.Cheerio<
|
|
13
|
+
$el: cheerio.Cheerio<cheerio.Element>,
|
|
14
14
|
traverse: () => AnyNode[],
|
|
15
15
|
context?: QueryContext
|
|
16
16
|
) => AnyNode | AnyNode[]
|
|
@@ -1,5 +1,19 @@
|
|
|
1
|
+
import { AnyNode, OldClip } from './Workarounds'
|
|
1
2
|
import tagMappings, { getBooleanAttributeValue } from './tagMappings'
|
|
2
3
|
import cheerio from 'cheerio'
|
|
4
|
+
|
|
5
|
+
function expectNotArray<T>(thing: T | T[]): asserts thing is T {
|
|
6
|
+
expect(thing).not.toBeInstanceOf(Array)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function expectNodeType<T extends AnyNode>(
|
|
10
|
+
node: AnyNode | AnyNode[],
|
|
11
|
+
type: T['type']
|
|
12
|
+
): asserts node is T {
|
|
13
|
+
expectNotArray(node)
|
|
14
|
+
expect(node.type).toBe(type)
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
describe('tagMappings test', () => {
|
|
4
18
|
it('getBooleanAttributeValue attrubute', () => {
|
|
5
19
|
let $el = cheerio.load('<ft-content autoplay></ft-content>')('ft-content')
|
|
@@ -20,7 +34,7 @@ describe('tagMappings test', () => {
|
|
|
20
34
|
const bodyXML = `
|
|
21
35
|
<body>
|
|
22
36
|
<experimental>
|
|
23
|
-
<ft-content autoplay=\"false\" caption=\"caption data\" data-asset-type=\"clip\" data-copyright=\"Reuters\"
|
|
37
|
+
<ft-content autoplay=\"false\" caption=\"caption data\" data-asset-type=\"clip\" data-copyright=\"Reuters\"
|
|
24
38
|
data-layout=\"in-line\" description=\"description\" href=\"https://storytelling-clips.s3.amazonaws.com/World/Joe%20Biden%20on%20debt%20ceiling%20deal%20v1.mp4\"
|
|
25
39
|
loop=\"true\" muted=\"false\" poster=\"https://d1e00ek4ebabms.cloudfront.net/production/65233b7d-5260-4acc-b01c-38ff470068e0.jpg\"
|
|
26
40
|
poster-alt=\"\" poster-copyright=\"Reuters\" poster-id=\"db0a46c6-9e60-4f46-bc0b-c158cb8394d0\" type=\"http://www.ft.com/ontology/content/clip\">
|
|
@@ -30,8 +44,9 @@ describe('tagMappings test', () => {
|
|
|
30
44
|
const selector =
|
|
31
45
|
'ft-content[type="http://www.ft.com/ontology/content/clip"]'
|
|
32
46
|
const $el = cheerio.load(bodyXML)(selector)
|
|
33
|
-
const mapping
|
|
34
|
-
|
|
47
|
+
const mapping = tagMappings[selector]($el, () => [])
|
|
48
|
+
|
|
49
|
+
expectNodeType<OldClip>(mapping, 'clip')
|
|
35
50
|
expect(mapping.url).toBe(
|
|
36
51
|
'https://storytelling-clips.s3.amazonaws.com/World/Joe%20Biden%20on%20debt%20ceiling%20deal%20v1.mp4'
|
|
37
52
|
)
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import * as scrollytelling from '@financial-times/n-scrollytelling-image/server'
|
|
21
21
|
import { LiteralToPrimitiveDeep } from 'type-fest'
|
|
22
22
|
import { CapiResponse } from '../../model/CapiResponse'
|
|
23
|
+
import type { Cheerio, Element } from 'cheerio'
|
|
23
24
|
|
|
24
25
|
const tableResponsiveStyleMap: Record<string, Table['responsiveStyle']> = {
|
|
25
26
|
stacked: 'flat',
|
|
@@ -623,8 +624,11 @@ const commonTagMappings: TagMappings = {
|
|
|
623
624
|
// We can't trust cheerio.attr() method since with attributes like "autoplay"
|
|
624
625
|
//doesn't return the value instead return "autoplay" and "autoplay" = "false"
|
|
625
626
|
//should be interpreted as false
|
|
626
|
-
export const getBooleanAttributeValue = (
|
|
627
|
-
|
|
627
|
+
export const getBooleanAttributeValue = (
|
|
628
|
+
$el: Cheerio<Element>,
|
|
629
|
+
attribute: string
|
|
630
|
+
) => {
|
|
631
|
+
const value = $el.get(0)?.attribs[attribute]
|
|
628
632
|
return [null, undefined, 'false', false].includes(value) ? false : true
|
|
629
633
|
}
|
|
630
634
|
|
package/src/types/cache.ts
CHANGED
|
@@ -11,7 +11,11 @@ interface ContextableCache<TContext extends QueryContext> {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function isContextableCache<TContext extends QueryContext>(
|
|
14
|
-
cache:
|
|
14
|
+
cache: unknown
|
|
15
15
|
): cache is ContextableCache<TContext> {
|
|
16
|
-
|
|
16
|
+
if (cache && typeof cache === 'object' && 'withContext' in cache) {
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return false
|
|
17
21
|
}
|