@financial-times/cp-content-pipeline-schema 2.0.3 → 2.2.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.
@@ -1,9 +1,10 @@
1
1
  import {
2
- WillSendRequestOptions,
3
2
  RESTDataSource,
4
- RequestOptions,
3
+ CacheOptions,
4
+ DataSourceFetchResult,
5
+ DataSourceRequest,
6
+ AugmentedRequest,
5
7
  } from '@apollo/datasource-rest'
6
- import type { FetcherResponse } from '@apollo/utils.fetcher'
7
8
  import { PrefixingKeyValueCache } from '@apollo/utils.keyvaluecache'
8
9
  import { SerializedRequest } from '@dotcom-reliability-kit/serialize-request'
9
10
  import { UpstreamServiceError } from '@dotcom-reliability-kit/errors'
@@ -40,69 +41,82 @@ export class InstrumentedRESTDataSource
40
41
  return this.constructor.name.replace(/DataSource$/, '').toLowerCase()
41
42
  }
42
43
 
43
- override willSendRequest(request: WillSendRequestOptions): void {
44
+ override willSendRequest(path: string, request: AugmentedRequest): void {
44
45
  request.headers[
45
46
  'user-agent'
46
47
  ] = `cp-content-pipeline-api/${this.context.versions.api} cp-content-pipeline-schema/${this.context.versions.schema} `
48
+
49
+ if (this.context.requestId && !this.context.contentRequestedOnce) {
50
+ request.headers['x-request-id'] = this.context.requestId
51
+ }
47
52
  }
48
53
 
49
- override async trace<TResult>(
50
- url: URL,
51
- request: WillSendRequestOptions,
52
- fn: () => Promise<TResult>
53
- ): Promise<TResult> {
54
- this.startTime = process.hrtime.bigint()
54
+ // we implement our own error handling so skip the built-in GraphQLError
55
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
56
+ async throwIfResponseIsError() {}
57
+
58
+ async fetch<TResult>(
59
+ path: string,
60
+ incomingRequest?: DataSourceRequest<CacheOptions>
61
+ ): Promise<DataSourceFetchResult<TResult>> {
62
+ const startTime = process.hrtime.bigint()
55
63
 
56
64
  this.context.metrics?.count(
57
65
  `graphql.datasource.${this.constructor.name}.request.count`,
58
66
  1
59
67
  )
68
+
60
69
  this.context.logger.info({
61
70
  event: 'REQUEST_DATASOURCE',
62
71
  datasource: this.constructor.name,
63
72
  request: {
64
73
  id: this.context.requestId,
65
- url: url.href,
66
- method: request.method,
74
+ url: new URL(path, this.baseURL).href,
75
+ method: incomingRequest?.method,
67
76
  } as SerializedRequest,
68
77
  })
69
78
 
70
- if (this.context.requestId && !this.context.contentRequestedOnce) {
71
- request.headers['x-request-id'] = this.context.requestId
72
- }
79
+ try {
80
+ const result = await super.fetch<TResult>(path, incomingRequest)
81
+ const duration = (process.hrtime.bigint() - startTime) / BigInt(1e6)
73
82
 
74
- return fn()
75
- }
83
+ this.context.metrics?.count(
84
+ `graphql.datasource.${this.constructor.name}.response.${result.response.status}.count`,
85
+ 1
86
+ )
76
87
 
77
- async didReceiveResponse<TResult>(
78
- response: FetcherResponse,
79
- request: RequestOptions
80
- ): Promise<TResult> {
81
- // this.startTime will definitely have been set by willSendRequest, but no way of encoding that in types
82
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
83
- const duration = (process.hrtime.bigint() - this.startTime!) / BigInt(1e6)
88
+ this.context.metrics?.count(
89
+ `graphql.datasource.${this.constructor.name}.response.${result.response.status}.time`,
90
+ Number(duration)
91
+ )
84
92
 
85
- this.context.metrics?.count(
86
- `graphql.datasource.${this.constructor.name}.response.${response.status}.count`,
87
- 1
88
- )
89
- this.context.metrics?.count(
90
- `graphql.datasource.${this.constructor.name}.response.${response.status}.time`,
91
- Number(duration)
92
- )
93
+ if (!result.response.ok) {
94
+ throw new UpstreamServiceError({
95
+ message: `${result.response.status}: ${result.response.statusText} from ${this.constructor.name}`,
96
+ statusCode: result.response.status,
97
+ relatesToSystems: this.backendSystemCodes,
98
+ url: result.response.url,
99
+ body: result.parsedBody,
100
+ })
101
+ }
102
+
103
+ return result
104
+ } catch (error) {
105
+ if (error instanceof Error && error.name === 'AbortError') {
106
+ const duration = (process.hrtime.bigint() - startTime) / BigInt(1e6)
107
+
108
+ this.context.metrics?.count(
109
+ `graphql.datasource.${this.constructor.name}.response.408.count`,
110
+ 1
111
+ )
112
+
113
+ this.context.metrics?.count(
114
+ `graphql.datasource.${this.constructor.name}.response.408.time`,
115
+ Number(duration)
116
+ )
117
+ }
93
118
 
94
- if (response.ok) {
95
- return super.didReceiveResponse(response, request)
96
- } else {
97
- const body = await this.parseBody(response)
98
-
99
- throw new UpstreamServiceError({
100
- message: `${response.status}: ${response.statusText} from ${this.constructor.name}`,
101
- statusCode: response.status,
102
- relatesToSystems: this.backendSystemCodes,
103
- url: response.url,
104
- body,
105
- })
119
+ throw error
106
120
  }
107
121
  }
108
122
  }
@@ -1,10 +1,6 @@
1
- import { WillSendRequestOptions } from '@apollo/datasource-rest'
2
- import {
3
- OperationalError,
4
- UpstreamServiceError,
5
- } from '@dotcom-reliability-kit/errors'
1
+ import { UpstreamServiceError } from '@dotcom-reliability-kit/errors'
6
2
  import { InstrumentedRESTDataSource } from './instrumented'
7
- import { CacheOptions } from '@apollo/datasource-rest/dist/RESTDataSource'
3
+ import { AugmentedRequest, CacheOptions } from '@apollo/datasource-rest'
8
4
 
9
5
  const REQUEST_TIMEOUT = 5000 // 5 seconds
10
6
 
@@ -19,8 +15,8 @@ export class OrigamiImageDataSource extends InstrumentedRESTDataSource {
19
15
  ? parseInt(process.env.IMAGE_METADATA_CACHE_TTL)
20
16
  : 60 * 60 * 24 * 7 // 1 week
21
17
 
22
- override willSendRequest(request: WillSendRequestOptions) {
23
- super.willSendRequest(request)
18
+ override willSendRequest(path: string, request: AugmentedRequest) {
19
+ super.willSendRequest(path, request)
24
20
 
25
21
  request.signal = this.abortController.signal
26
22
  this.timeout = setTimeout(
@@ -1,11 +1,5 @@
1
- import {
2
- CacheOptions,
3
- WillSendRequestOptions,
4
- } from '@apollo/datasource-rest/dist/RESTDataSource'
5
- import {
6
- OperationalError,
7
- UpstreamServiceError,
8
- } from '@dotcom-reliability-kit/errors'
1
+ import { AugmentedRequest, CacheOptions } from '@apollo/datasource-rest'
2
+ import { UpstreamServiceError } from '@dotcom-reliability-kit/errors'
9
3
  import { InstrumentedRESTDataSource } from './instrumented'
10
4
 
11
5
  const REQUEST_TIMEOUT = 5000 // 5 seconds
@@ -16,8 +10,8 @@ export class TwitterDataSource extends InstrumentedRESTDataSource {
16
10
  abortController = new AbortController()
17
11
  timeout: ReturnType<typeof setTimeout> | undefined = undefined
18
12
 
19
- override willSendRequest(request: WillSendRequestOptions) {
20
- super.willSendRequest(request)
13
+ override willSendRequest(path: string, request: AugmentedRequest) {
14
+ super.willSendRequest(path, request)
21
15
 
22
16
  request.signal = this.abortController.signal
23
17
  this.timeout = setTimeout(
@@ -637,6 +637,7 @@ export type LiveBlogPost = Content & {
637
637
  readonly editorialDesk?: Maybe<Scalars['String']['output']>;
638
638
  readonly firstPublishedDate: Scalars['String']['output'];
639
639
  readonly id: Scalars['ID']['output'];
640
+ readonly indicators?: Maybe<Indicators>;
640
641
  readonly instantAlertConcept?: Maybe<Concept>;
641
642
  readonly mainImage?: Maybe<Image>;
642
643
  readonly originatingParty?: Maybe<Scalars['String']['output']>;
@@ -1780,6 +1781,7 @@ export type LiveBlogPostResolvers<ContextType = QueryContext, ParentType extends
1780
1781
  editorialDesk?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
1781
1782
  firstPublishedDate?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
1782
1783
  id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
1784
+ indicators?: Resolver<Maybe<ResolversTypes['Indicators']>, ParentType, ContextType>;
1783
1785
  instantAlertConcept?: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
1784
1786
  mainImage?: Resolver<Maybe<ResolversTypes['Image']>, ParentType, ContextType>;
1785
1787
  originatingParty?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@@ -270,6 +270,14 @@ export class CapiResponse {
270
270
  return null
271
271
  }
272
272
  title() {
273
+ // CI-2038 HACK to remove "Comment:" prefix from live blog post titles
274
+ // as this is as the sole indicator that this is an opinion post
275
+ // while we're waiting for the annotation data to be in CAPI
276
+ if (this.type() === 'LiveBlogPost') {
277
+ if (this.capiData.title.startsWith('Comment:')) {
278
+ return this.capiData.title.replace('Comment:', '').trim()
279
+ }
280
+ }
273
281
  return this.capiData.title
274
282
  }
275
283
  id() {
@@ -507,6 +515,14 @@ export class CapiResponse {
507
515
  }
508
516
 
509
517
  isOpinion() {
518
+ // CI-2038 HACK to identify live blog opinion posts by the "Comment:" prefix in the title
519
+ // while we're waiting for the annotation data to be in CAPI
520
+ if (
521
+ this.type() === 'LiveBlogPost' &&
522
+ this.capiData.title.startsWith('Comment:')
523
+ ) {
524
+ return true
525
+ }
510
526
  return this.annotations().some((annotation: Concept) =>
511
527
  annotation.isGenre('opinion')
512
528
  )
@@ -121,15 +121,19 @@ export const ClipSet = z.object({
121
121
  source: z.string().optional(),
122
122
  subtitle: z.string().optional(),
123
123
  publishedDate: z.string().optional(),
124
- accessibility: z.object({
125
- captions: z.array(
126
- z.object({
127
- mediaType: z.string().optional(),
128
- url: z.string().optional(),
129
- })
130
- ).optional(),
131
- transcript: z.string().optional(),
132
- }).optional(),
124
+ accessibility: z
125
+ .object({
126
+ captions: z
127
+ .array(
128
+ z.object({
129
+ mediaType: z.string().optional(),
130
+ url: z.string().optional(),
131
+ })
132
+ )
133
+ .optional(),
134
+ transcript: z.string().optional(),
135
+ })
136
+ .optional(),
133
137
  })
134
138
 
135
139
  export const DataSource = z.object({
@@ -7,6 +7,7 @@ import {
7
7
  ContentPackageResolvers,
8
8
  ContentResolvers,
9
9
  LiveBlogPackageResolvers,
10
+ LiveBlogPostResolvers,
10
11
  } from '../generated'
11
12
  import { RichText } from '../model/RichText'
12
13
 
@@ -72,6 +73,11 @@ const resolvers = {
72
73
 
73
74
  LiveBlogPost: {
74
75
  containedIn: (parent) => parent.containedIn(),
76
+ indicators(parent) {
77
+ return {
78
+ isOpinion: parent.isOpinion(),
79
+ }
80
+ },
75
81
  },
76
82
 
77
83
  LiveBlogPackage: {
@@ -94,7 +100,7 @@ const resolvers = {
94
100
  } satisfies {
95
101
  Article: ArticleResolvers
96
102
  Placeholder: PlaceholderResolvers
97
- LiveBlogPost: ArticleResolvers
103
+ LiveBlogPost: LiveBlogPostResolvers
98
104
  Content: ContentResolvers
99
105
  LiveBlogPackage: LiveBlogPackageResolvers
100
106
  ContentPackage: ContentPackageResolvers